Compare commits

..

5 Commits

Author SHA1 Message Date
pashpashpash
ae03b79b73 Fail fast for removed Codex import auth choice 2026-04-22 19:12:21 -07:00
pashpashpash
d507d5a766 Preserve removed Codex import auth choice 2026-04-22 19:07:02 -07:00
pashpashpash
a7a3a5f37b Fix legacy update compat sidecars 2026-04-22 19:07:02 -07:00
pashpashpash
2476992301 Remove stale Codex import auth choice 2026-04-22 19:06:28 -07:00
pashpashpash
1151e7d40b Remove Codex CLI auth import 2026-04-22 19:06:28 -07:00
5282 changed files with 73403 additions and 345322 deletions

View File

@@ -1,6 +1,11 @@
---
name: blacksmith-testbox
description: Run Blacksmith Testbox for CI-parity checks, secrets, hosted services, migrations, or builds local cannot reproduce.
description: >
Validate code changes against real CI when local execution is not
enough. Use for CI-parity checks, secrets/services, migrations, or
builds/tests that cannot run reliably on the local machine. Do not
replace repo-documented local test/build loops just because this
skill exists.
---
# Blacksmith Testbox

View File

@@ -1,6 +1,6 @@
---
name: openclaw-ghsa-maintainer
description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state.
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
---
# OpenClaw GHSA Maintainer

View File

@@ -1,6 +1,6 @@
---
name: openclaw-parallels-smoke
description: Run, rerun, debug, or interpret OpenClaw Parallels install, onboarding, gateway smoke, and upgrade checks.
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
---
# OpenClaw Parallels Smoke
@@ -45,9 +45,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
## npm install then update
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- For a macOS-only published release update check, use:
- `timeout --foreground 75m pnpm test:parallels:npm-update -- --platform macos --package-spec openclaw@<old-version> --update-target <target-version-or-tag> --json`
This keeps the same-guest `openclaw update --tag ...` coverage and uses the shared macOS current-user/sudo fallback without starting Windows/Linux lanes.
- Required coverage: every release/update regression run must include both lanes:
- fresh snapshot -> install requested package/baseline -> smoke
- same guest baseline -> run the guest's installed `openclaw update ...` command -> smoke again
@@ -78,7 +75,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- `parallels-macos-smoke.sh --mode fresh --target-package-spec openclaw@<version>` is an install smoke only. For published old-version -> new-version update coverage on macOS, prefer the npm-update wrapper with `--platform macos`; `parallels-macos-smoke.sh --mode upgrade --target-package-spec ...` installs the target package and does not exercise the baseline CLI's updater.
- Default upgrade coverage on macOS should now include: fresh snapshot -> site installer pinned to the latest stable tag -> `openclaw update --channel dev` on the guest. Treat this as part of the default Tahoe regression plan, not an optional side quest.
- `parallels-macos-smoke.sh --mode upgrade` should run that release-to-dev lane by default. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Because the default upgrade lane no longer needs a host tgz, skip `npm pack` + host HTTP server startup for `--mode upgrade` unless `--target-package-spec` is set. Keep the pack/server path for `fresh` and `both`.
@@ -148,7 +144,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- `--discord-token-env`
- `--discord-guild-id`
- `--discord-channel-id`
- After a successful Discord smoke/roundtrip, shut down the guest VM before handoff (`prlctl stop "$VM_NAME"` or the concrete VM name). The macOS smoke harness should do this automatically after successful Discord proof; still stop the VM manually after ad-hoc Discord checks. Do not leave the Discord-configured guest running; it can keep reading/posting in `#maintainer` and spam Discord after the proof is complete.
- Keep the Discord token only in a host env var.
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.

View File

@@ -1,28 +1,12 @@
---
name: openclaw-pr-maintainer
description: Review, triage, close, label, comment on, or land OpenClaw PRs/issues with maintainer evidence checks.
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
---
# OpenClaw PR Maintainer
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
## Start issue and PR triage with ghcrawl
- Anytime you inspect OpenClaw issues or PRs, check local `ghcrawl` data first for related threads, duplicate attempts, and already-landed fixes.
- Use `ghcrawl` for candidate discovery and clustering; use `gh`, `gh api`, and the current checkout to verify live state before commenting, labeling, closing, or landing.
- If `ghcrawl` is missing, stale, lacks the target thread, or has no embeddings for neighbor/search commands, fall back to the GitHub search workflow below.
- Do not run expensive/update commands such as `ghcrawl refresh`, `ghcrawl embed`, or `ghcrawl cluster` unless the user asked to update the local store or the stale data is blocking the decision.
Common read-only path:
```bash
ghcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
ghcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
ghcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --json
ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
## Apply close and triage labels correctly
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
@@ -51,21 +35,6 @@ ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --b
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
## Close low-signal manual PRs carefully
- Do not close for red CI alone. Require a clear low-signal category plus stale or failed validation.
- Good manual-close categories:
- blank or mostly untouched PR template with no concrete OpenClaw problem/fix
- random docs-only churn such as root README translations, generic wording tweaks, or community-plugin discoverability docs that should go through ClawHub
- test-only coverage without a linked bug, owner request, or behavior change
- refactor-only cleanup, variable renames, formatting, or generated/baseline churn without maintainer request
- third-party channel/provider/tool/skill/plugin work that belongs on ClawHub instead of core
- risky ops/infra drive-bys such as new external CI services, release workflows, host upgrade scripts, Docker base migrations, or apt retry/fix-missing tweaks without owner request and green validation
- dirty branches where a narrow stated change includes unrelated docs/generated/runtime/extension files
- repeated bot-review spam or copied bot output without author-owned fixes
- Keep or escalate plausible focused bug fixes, green PRs, active maintainer discussions, assigned work, recent author follow-up, and unique reproduction details.
- For third-party capabilities, prefer the `r: third-party-extension` auto-response label when it applies; it points contributors to publish on ClawHub.
## Handle GitHub text safely
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
@@ -75,9 +44,9 @@ ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --b
## Search broadly before deciding
- Prefer `ghcrawl` first. Then use targeted GitHub keyword search to verify gaps, live status, comments, and candidates not present in the local store.
- Use `--repo openclaw/openclaw` with `--match title,body` first when using `gh search`.
- Add `--match comments` when triaging follow-up discussion or closed-as-duplicate chains.
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
- Use `--repo openclaw/openclaw` with `--match title,body` first.
- Add `--match comments` when triaging follow-up discussion.
- Do not stop at the first 500 results when the task requires a full search.
Examples:
@@ -99,7 +68,6 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
- Do not commit PR-only artifacts such as screenshots under `.github/pr-assets`; attach them to the PR/comment or use an external artifact store instead.
## Extra safety

View File

@@ -1,6 +1,6 @@
---
name: openclaw-qa-testing
description: Run, watch, debug, extend, or explain OpenClaw qa-lab and qa-channel scenarios, artifacts, and live lanes.
description: Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
---
# OpenClaw QA Testing
@@ -49,79 +49,6 @@ pnpm openclaw qa suite \
5. If the user wants to watch the live UI, find the current `openclaw-qa` listen port and report `http://127.0.0.1:<port>`.
6. If a scenario fails, fix the product or harness root cause, then rerun the full lane.
## OTEL smoke
For local QA-lab OpenTelemetry validation, use:
```bash
pnpm qa:otel:smoke
```
This starts a local OTLP/HTTP trace receiver, runs the `otel-trace-smoke`
scenario through qa-channel, decodes the emitted protobuf spans, and verifies
the exported trace names and privacy contract. It does not require Opik,
Langfuse, or external collector credentials.
## QA credentials and 1Password
- Use `op` only inside `tmux` for QA secret lookup in this repo.
- Quick auth check inside tmux:
```bash
op account list
```
- Direct Telegram npm live test secrets currently live in 1Password item:
- vault: `OpenClaw`
- item: `Telegram E2E`
- That item is the first place to look for:
- `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`
- `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`
- `OPENCLAW_QA_PROVIDER_MODE`
- `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC`
- Convex QA secrets currently live in 1Password items:
- vault: `OpenClaw`
- item: `OPENCLAW_QA_CONVEX_SITE_URL`
- item: `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`
- item: `OPENCLAW_QA_CONVEX_SECRET_CI`
- Additional related notes/login items seen during QA credential work:
- vault: `Private`
- items: `OPENCLAW QA`, `Convex`, `Telegram`
- If a required value is missing from those notes:
- do not guess
- ask the maintainer/operator for the current value or the current 1Password item name
- for Telegram direct runs, `OPENCLAW_QA_TELEGRAM_GROUP_ID` may be stored separately from `Telegram E2E`
- for Convex runs, the leased Telegram credential should provide the Telegram group id and bot tokens together; do not require a separate `OPENCLAW_QA_TELEGRAM_GROUP_ID`
- for Convex runs, prefer `OpenClaw/OPENCLAW_QA_CONVEX_SITE_URL`; if that is stale or unclear, ask for the active pool URL before running
- Prefer direct Telegram envs for the npm Telegram Docker lane when available:
```bash
OPENCLAW_QA_TELEGRAM_GROUP_ID="..." \
OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN="..." \
OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN="..." \
OPENCLAW_QA_PROVIDER_MODE="mock-openai" \
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="openclaw@beta" \
pnpm test:docker:npm-telegram-live
```
- Prefer Convex mode when the goal is stable shared QA infra:
- round-robin credential leasing
- thinner wrapper for channel-specific setup
- CLI/admin flows around the pooled credentials
- Live npm Telegram Docker lane note:
- `scripts/e2e/npm-telegram-live-runner.ts` reads `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE`
- do not assume `OPENCLAW_QA_PROVIDER_MODE` is consumed by that wrapper
- if a 1Password note only gives `OPENCLAW_QA_PROVIDER_MODE`, map it explicitly to `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE` before running the Docker lane
- Verified live shape:
- Convex mode can pass the real Docker lane without direct Telegram env vars
- leased Telegram payload includes the group id coupled to the driver/SUT tokens
- a real run of `pnpm test:docker:npm-telegram-live` passed with:
- `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`
- `OPENCLAW_QA_CREDENTIAL_ROLE=maintainer`
- `OPENCLAW_QA_CONVEX_SITE_URL`
- `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`
- `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE=mock-openai`
## Character evals
Use `qa character-eval` for style/persona/vibe checks across multiple live models.

View File

@@ -1,6 +1,6 @@
---
name: openclaw-release-maintainer
description: Prepare or verify OpenClaw stable/beta releases, changelogs, release notes, publish commands, and artifacts.
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
---
# OpenClaw Release Maintainer
@@ -28,17 +28,12 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Do not delete or rewrite beta tags after they leave the machine. If a
published or pushed beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- For a beta release train, run the fast local preflight first, publish the
beta to npm `beta`, then run the expensive published-package roster focused
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
the release branch, commit/push/pull, increment beta number, and repeat. Run
the full expensive roster at least once before stable/latest promotion; for
later beta attempts, rerun only lanes whose evidence changed unless the fix
touches broad release, install/update, plugin, Docker, Parallels, or live QA
behavior. After each beta is published, scan current `main` once for critical
fixes that landed after the release branch cut and backport only important
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
after 4 failed beta attempts, stop and report.
- For a beta release train, run the full pre-npm test roster before publishing
each beta. After a beta is published, run the smaller published-install roster
focused on install/update/Docker/Parallels. If anything fails, fix it on the
release branch, commit/push/pull, increment beta number, and repeat. Operators
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
stop and report.
- Use `/changelog` before version/tag preparation so the top changelog section
is deduped and ordered by user impact.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
@@ -75,26 +70,6 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Every stable OpenClaw release ships the npm package and macOS app together.
Beta releases normally ship npm/package artifacts first and skip mac app
build/sign/notarize unless the operator requests mac beta validation.
- Do not let the slower macOS signing/notary path block npm publication once
the npm preflight has passed. Keep mac validation/publish running in
parallel, publish npm from the successful npm preflight, then start published
npm install/update, Docker, and Parallels verification while mac artifacts
continue.
- After a beta is published, run the expensive Docker, Parallels, and QA-Lab
release rosters in parallel instead of serializing them. Use selective reruns
after failures or fixes, but keep proof that Docker, Parallels, and QA-Lab
each passed at least once before stable/latest promotion.
- Mac packaging may be built from a slight release-branch variation of the
tagged commit when the delta is mac packaging, signing, workflow, or
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.D-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with
`source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction
tags for emergency hotfixes that must publish a new npm package/release
identity, not for ordinary mac-only packaging recovery.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
@@ -106,16 +81,7 @@ Use this skill for release and publish-time workflow. Keep ordinary development
## Build changelog-backed release notes
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
section from commit history, not just from existing notes: scan commits since
the last reachable release tag, add missed user-facing changes, dedupe
overlapping entries, and sort each section from most to least interesting for
users.
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.D` through the line before the
next level-2 heading and use that complete block as the release notes.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
@@ -138,33 +104,14 @@ live`; keep it clearly beta and avoid implying stable promotion.
- Lead with user-visible capabilities, then important integrations, then
reliability/security/install fixes. Compress "lots of fixes" into one
readable bullet.
- Read the full changelog section before drafting. Do not lead with coverage,
CI, validation, or internal release mechanics unless the release is explicitly
about those. Peter prefers concrete user wins: features, integrations,
workflow improvements, and practical reliability fixes.
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
enough. Avoid punching down, insulting users, or promising what was not
verified.
- Peter likes dry, compact taglines when they feel earned. Good example:
`Big release, tiny release notes... kidding.` Keep the joke short and let the
feature bullets carry the tweet; do not turn the punchline into a second
paragraph or a forced bit.
- Length: release tweets are always standard tweets under 280 characters, with
room for one URL. Trim to 3-4 bullets and count the final text before posting.
- Links/media: include the GitHub release or changelog link at the end of the
first release tweet.
- Thread follow-ups: if doing a thread, keep the first release tweet as the
compact launch post, then publish one focused feature explainer per reply.
Follow-up replies should not repeat "new in VERSION" or the version number
when the thread context already makes it obvious.
- Every follow-up tweet should include a docs URL for that specific feature.
Prefer a bare URL over `Docs: <url>` unless the label is needed for clarity.
Keep follow-ups concise: around 160-220 raw characters is usually the sweet
spot; under 280 is the hard cap. If a URL makes a tweet fail, trim prose
before dropping the URL.
Prefer explaining diagnostics, trajectory/export, provider setup, model
commands, or other setup-heavy features in follow-ups instead of overloading
the first release tweet.
- Length: release tweets are always standard tweets under 280 characters. Trim
to 3-4 bullets and count the final text before posting.
- Links/media: include the GitHub release or changelog link at the end. Add a
short docs follow-up reply only when there is a standout feature that needs
setup instructions.
- Hotfix/correction: be direct and accountable. State what slipped, what is
fixed, and the new version. Keep jokes out of incident-style posts.
@@ -211,16 +158,10 @@ Before tagging or publishing, run:
pnpm check:architecture
pnpm build
pnpm ui:build
pnpm qa:otel:smoke
pnpm release:check
pnpm test:install:smoke
```
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
without external Opik or Langfuse credentials.
For a non-root smoke path:
```bash
@@ -260,18 +201,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Source Peter's profile before live release validation so OpenAI and Anthropic
credentials are available without printing secrets:
`set -a; source "$HOME/.profile"; set +a`.
- Parallels validation and any local live model QA for this train must use both
- Release QA and Parallels validation for this train must use both
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. If either is missing after sourcing
`.profile`, stop before starting those local long lanes and report the
missing key.
- Live credentialed channel QA is the GitHub Actions workflow
`QA-Lab - All Lanes` (`.github/workflows/qa-live-telegram-convex.yml`), not a
local substitute. Dispatch it from Actions against the release tag and wait
for it to pass before npm preflight/publish readiness. Use a SHA only when it
satisfies the workflow's secret-bearing trust gate: main ancestor or open PR
head. It runs the QA Lab mock parity gate plus live Matrix and live Telegram
lanes using the `qa-live-shared` environment; Telegram uses Convex CI
credential leases.
`.profile`, stop before starting the long lanes and report the missing key.
- Default release checks:
- `pnpm check`
- `pnpm check:test-types`
@@ -289,42 +221,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- all Parallels install/update tests:
`pnpm test:parallels:npm-update -- --json` plus any needed individual
rerun lanes from `openclaw-parallels-smoke`
- all QA release validation: dispatch GitHub Actions > `QA-Lab - All Lanes`
against the release tag and require success. This is the release gate for
live credentialed Matrix/Telegram channel coverage. Use a SHA only when it
satisfies the workflow trust gate. Run local OpenAI/Anthropic suites or
repo-backed character evals only when the operator asks for extra model
coverage or a failure needs local debugging.
- all QA release validation:
OpenAI live suite with `openai/gpt-5.4` in fast mode, Anthropic live suite
with `anthropic/claude-opus-4-6`, and the repo-backed character evals
- Post-published beta verification roster:
- `node --import tsx scripts/openclaw-npm-postpublish-verify.ts <beta-version>`
- install/update smoke against the published beta channel
- Docker install/update coverage that exercises the published beta package
- published npm Telegram proof: dispatch Actions > `NPM Telegram Beta E2E`
from `main` with `package_spec=openclaw@<beta-version>` and
`provider_mode=mock-openai`, approve `npm-release`, and require success.
This is the default button path for installed-package onboarding,
Telegram setup, and real Telegram E2E against the published npm package.
Use the local `pnpm test:docker:npm-telegram-live` lane with the matching
`OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC` and Convex CI env only as a fallback
or debugging path.
- Parallels published beta install/update coverage with both OpenAI and
Anthropic provider keys available
- Parallels install/update proof must keep plugin installs enabled unless the
operator explicitly scopes a harness-only isolation check; a lane that
disables bundled plugin installs is not valid plugin/dependency release
evidence.
- targeted QA reruns only for areas touched by fixes after the full pre-npm
roster, unless the operator requests the full QA roster again. If the fix
touches live channel QA, credential plumbing, Matrix, Telegram, or the QA
harness, rerun Actions > `QA-Lab - All Lanes`.
roster, unless the operator requests the full QA roster again
- Check all release-related build surfaces touched by the release, not only the npm package.
- For beta-style full e2e batteries, hard-cap top-level long lanes instead of letting them run indefinitely. Use host `timeout --foreground`/`gtimeout --foreground` caps such as:
- `45m` for `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- `90m` for `pnpm test:docker:all`
- `60m` each for standalone Docker live lanes
- `180m` for local full QA live OpenAI + Anthropic rosters when explicitly
requested; the default release channel QA gate is Actions >
`QA-Lab - All Lanes`
- `180m` for the full QA live OpenAI + Anthropic roster
- Parallels caps from the `openclaw-parallels-smoke` skill
If a lane hits its cap, stop and inspect/fix the affected lane before continuing; do not continue to wait on the same process.
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
@@ -344,14 +257,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
public release assets so the updater feed cannot lag the published binaries.
- Serialize stable appcast-producing runs across tags so two releases do not
generate replacement `appcast.xml` files from the same stale seed.
- For stable releases, rely primarily on the latest beta's broader release
workflow confidence. When promoting the matching non-beta build to npm
`latest`, prefer a light time-bounded verification pass: published npm
postpublish verify, Docker install/update smoke, macOS-only Parallels
install/update smoke, and required QA signal. Do not rerun the full
Docker/Parallels matrix unless the beta evidence is stale, the stable build
differs materially from beta, or the operator explicitly asks for full
retesting.
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
## Use the right auth flow
@@ -361,29 +267,6 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
public npm release workflow stays OIDC-only.
- Prefer fixing the private workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the private
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
and 2FA bypass for automation.
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
or stale, use the local tmux + 1Password fallback:
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
are observable and recoverable.
- Hard rule: never run `op` directly in the main agent shell during release
work. Any 1Password CLI use must happen inside that tmux session so prompts
and alerts are contained and observable.
- Use the 1Password item `op://Private/Npmjs` for npm credentials and OTP.
Do not print passwords, tokens, or OTPs to the transcript; send them through
tmux buffers, env vars scoped to the tmux command, or `expect` with
`log_user 0`.
- Re-authenticate npm inside that tmux session with
`npm login --auth-type=legacy`, then confirm `npm whoami` reports
`steipete`.
- Promote with a fresh OTP:
`npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`.
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
- Direct stable publishes can also use that private dist-tag workflow to point
`beta` at the already-published `latest` version when the operator wants both
tags aligned immediately.
@@ -500,89 +383,73 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
7. Make every repo version location match the beta tag before creating it.
8. Commit release preparation changes on the release branch and push the branch.
9. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Keep expensive Docker, Parallels, and published-package
install/update lanes for after the beta is live unless the operator asks to
run them before beta publication.
9. Run the full pre-npm beta test roster from the release branch before any npm
preflight or publish.
10. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.
11. Confirm the target npm version is not already published.
12. Create and push the git tag from the release branch.
13. Create or refresh the matching GitHub release.
14. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
15. Start `.github/workflows/openclaw-npm-release.yml` from the release branch
14. Start `.github/workflows/openclaw-npm-release.yml` from the release branch
with `preflight_only=true`
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
an intentional direct stable publish). Wait for it to pass. Save that run id
because the real publish requires it to reuse the prepared npm tarball.
16. For stable releases, start `.github/workflows/macos-release.yml` in
15. For stable releases, start `.github/workflows/macos-release.yml` in
`openclaw/openclaw` and wait for the public validation-only run to pass.
17. For stable releases, start
16. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
18. For stable releases, start
17. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
19. If any preflight or validation run fails, fix the issue on a new commit,
18. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
20. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
19. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
22. Run postpublish verification:
20. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
21. Run postpublish verification:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
23. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to
the next beta if the fix must change the already-published package. If any
lane fails after the beta tag/package is pushed or published, fix,
commit/push/pull, increment to the next beta tag, and rerun the affected
beta evidence. Start Docker, Parallels, and QA-Lab in parallel once the
beta is live. Ensure the full expensive roster has passed at least once
before stable/latest promotion. The roster includes the manual Actions >
`NPM Telegram Beta E2E` workflow against the exact published beta package.
If a pre-npm lane fails before any tag/package leaves the machine, fix and
rerun the same intended beta attempt. Repeat up to the operator's
authorized beta-attempt limit, normally 4.
24. Announce the beta/stable release on Discord best-effort using Peter's bot
22. Run the post-published beta verification roster. If any lane fails after
the beta tag/package is pushed or published, fix, commit/push/pull,
increment to the next beta tag, and restart at the full pre-npm beta test
roster for the new beta. If a pre-npm lane fails before any tag/package
leaves the machine, fix and rerun the same intended beta attempt. Repeat up
to the operator's authorized beta-attempt limit, normally 4.
23. Announce the beta/stable release on Discord best-effort using Peter's bot
token from `.profile`.
25. If the operator requested beta only, stop after beta verification and the
24. If the operator requested beta only, stop after beta verification and the
announcement.
26. If the stable release was published to `beta`, use the light stable
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
Then start the private
25. If the stable release was published to `beta`, start the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`, then
verify `latest` now points at that version.
27. If the stable release was published directly to `latest` and `beta` should
workflow after beta validation passes to promote that stable version from
`beta` to `latest`, then verify `latest` now points at that version.
26. If the stable release was published directly to `latest` and `beta` should
follow it, start that same private dist-tag workflow to point `beta` at the
stable version, then verify both `latest` and `beta` point at that version.
28. For stable releases, start
27. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
29. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
28. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
30. For stable releases, download `macos-appcast-<tag>` from the successful
29. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
or cherry-pick release branch changes back to `main` after stable succeeds.
31. For beta releases, publish the mac assets only when intentionally requested;
30. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
32. After publish, verify npm and the attached release artifacts.
31. After publish, verify npm and the attached release artifacts.
## GHSA advisory work

View File

@@ -1,6 +1,6 @@
---
name: openclaw-secret-scanning-maintainer
description: Triage, redact, clean up, and resolve OpenClaw GitHub Secret Scanning alerts in issues or PRs.
description: Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
---
# OpenClaw Secret Scanning Maintainer

View File

@@ -1,6 +1,6 @@
---
name: openclaw-test-heap-leaks
description: Investigate OpenClaw pnpm test memory growth, Vitest OOMs, RSS spikes, and heap snapshot deltas.
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, triage likely transformed-module retention versus likely runtime leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks

View File

@@ -1,6 +1,6 @@
---
name: openclaw-test-performance
description: Benchmark, diagnose, and optimize OpenClaw test runtime, import hotspots, CPU/RSS, and slow coverage paths.
description: Benchmark, diagnose, and optimize OpenClaw test performance without losing coverage. Use when Codex needs to reassess `pnpm test`, compare grouped Vitest reports, identify CPU/memory/import hotspots, fix slow tests or cold runtime paths, preserve behavior proofs, update the performance report, add AGENTS guardrails, and make scoped commits/pushes for OpenClaw test-speed work.
---
# OpenClaw Test Performance

View File

@@ -1,6 +1,6 @@
---
name: optimizetests
description: Optimize OpenClaw slow tests, imports, misplaced coverage, and CI wall time without dropping coverage.
description: Optimize OpenClaw test runtime end to end. Use when the user asks for /optimizetests, slow-test review, import optimization, deduping tests, moving misplaced core coverage to extensions, or reducing CI/test wall time without adding shards or dropping coverage.
---
# Optimize Tests

View File

@@ -1,6 +1,6 @@
---
name: parallels-discord-roundtrip
description: Run macOS Parallels smoke with Discord send, host verification, host reply, and guest readback proof.
description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback.
---
# Parallels Discord Roundtrip
@@ -50,7 +50,6 @@ pnpm test:parallels:macos \
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
- Harness cleanup deletes the temporary Discord smoke messages at exit.
- After a successful Discord roundtrip, shut down the macOS guest before handoff (`prlctl stop "macOS Tahoe"`). The macOS smoke harness should do this automatically after successful Discord proof; still stop the VM manually after ad-hoc Discord checks. Do not leave the Discord-configured VM running; it can keep reading/posting in `#maintainer` and spam Discord after the proof is complete.
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
- Machine summary: pass `--json`
- If roundtrip flakes, inspect `fresh.discord-roundtrip.log` and `discord-last-readback.json` in the run dir first.

View File

@@ -1,6 +1,6 @@
---
name: security-triage
description: Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof.
description: Triage GitHub security advisories for OpenClaw with high-confidence close/keep decisions, exact tag and commit verification, trust-model checks, optional hardening notes, and a final reply ready to post and copy to clipboard.
---
# Security Triage
@@ -45,17 +45,6 @@ For each advisory, decide:
- `keep open`
- `keep open but narrow`
Default to one advisory at a time when comments/closures are involved:
1. Review exactly one GHSA.
2. Print the GHSA URL first.
3. Summarize the decision and evidence for discussion.
4. Draft one maintainer-ready comment.
5. Copy only that one comment to the clipboard.
6. Stop and wait for Peter to post/discuss before moving to the next GHSA.
Do not batch multiple close comments unless Peter explicitly asks for a batch.
Check in this order:
1. Trust model
@@ -71,11 +60,6 @@ Check in this order:
4. Functional tradeoff
- If a hardening change would reduce intended user functionality, call that out before proposing it.
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
5. Hardening follow-up
- Even when the GHSA should close, ask whether a narrow hardening change would reduce footguns without changing the documented trust boundary.
- Separate hardening from vulnerability status. Phrase it as "not required for GHSA closure, but worth considering".
- Bring up hardening only if it is concrete, low-risk, and preserves intended maintainer/operator workflows.
- If hardening would require a product/security model change, say that explicitly and do not imply it is a required fix for closure.
## Response Format
@@ -92,22 +76,9 @@ When preparing a maintainer-ready close reply:
Keep tone firm, specific, non-defensive.
## Discussion Mode
When Peter is manually posting GHSA comments, use this flow:
1. Show the URL.
2. Give a terse verdict (`close`, `keep open`, or `keep open but narrow`).
3. List the strongest evidence bullets.
4. State any optional hardening follow-up separately from the close reason.
5. Copy the proposed comment body with `pbcopy`.
6. End the reply after the one advisory. Do not continue to the next advisory until Peter says to continue.
If the GitHub API cannot post comments for private advisories, say so once and keep using clipboard/UI paste.
## Clipboard Step
After drafting the final post body for the current advisory, copy it:
After drafting the final post body, copy it:
```bash
pbcopy <<'EOF'
@@ -115,7 +86,7 @@ pbcopy <<'EOF'
EOF
```
Tell the user that the clipboard now contains the proposed response for that advisory.
Tell the user that the clipboard now contains the proposed response.
## Useful Commands

View File

@@ -1,6 +1,6 @@
---
name: tag-duplicate-prs-issues
description: Search duplicate OpenClaw PRs/issues, group related work in prtags, and sync duplicate state to GitHub.
description: Maintainer workflow for deciding whether an OpenClaw pull request or issue is a duplicate, gathering evidence with ghreplica and pr-search-cli, grouping related work in prtags, and syncing the duplicate grouping back to GitHub through prtags. Use when Codex needs to search for duplicate PRs or issues, create or reuse a duplicate group, enforce one-group-per-target discipline, save duplicate judgments in prtags, or prepare group state for comment sync.
---
# Tag Duplicate PRs and Issues

View File

@@ -8,14 +8,6 @@
.bun-cache
.bun
.artifacts
**/.artifacts
.local
**/.local
.pi
**/.pi
__openclaw_vitest__
**/__openclaw_vitest__
.tmp
**/.tmp
.DS_Store
@@ -46,9 +38,6 @@ docs/.generated
*.log
tmp
**/tmp
dist-runtime
**/dist-runtime
openclaw-path-alias-*
# build artifacts
dist

View File

@@ -82,5 +82,4 @@ OPENCLAW_GATEWAY_TOKEN=
# ELEVENLABS_API_KEY=...
# XI_API_KEY=... # alias for ElevenLabs
# INWORLD_API_KEY=...
# DEEPGRAM_API_KEY=...

View File

@@ -37,7 +37,6 @@ runs:
check-latest: false
- name: Setup pnpm + cache store
id: pnpm-cache
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: ${{ inputs.pnpm-version }}
@@ -98,11 +97,3 @@ runs:
install_args+=("$LOCKFILE_FLAG")
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
- name: Save pnpm store cache
if: inputs.install-deps == 'true' && steps.pnpm-cache.outputs.cache-enabled == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.primary-key }}

View File

@@ -14,25 +14,9 @@ inputs:
required: false
default: "true"
use-actions-cache:
description: Whether to restore pnpm store with actions/cache.
description: Whether to restore/save pnpm store with actions/cache.
required: false
default: "true"
outputs:
cache-enabled:
description: Whether actions/cache restore was enabled.
value: ${{ steps.pnpm-cache-config.outputs.enabled }}
cache-hit:
description: Whether the pnpm store cache had an exact key hit.
value: ${{ steps.pnpm-cache-restore.outputs.cache-hit }}
cache-matched-key:
description: Cache key matched by restore, if any.
value: ${{ steps.pnpm-cache-restore.outputs.cache-matched-key }}
primary-key:
description: Primary pnpm store cache key.
value: ${{ steps.pnpm-cache-config.outputs.primary-key }}
store-path:
description: Resolved pnpm store path.
value: ${{ steps.pnpm-store.outputs.path }}
runs:
using: composite
steps:
@@ -62,29 +46,18 @@ runs:
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Resolve pnpm store cache keys
id: pnpm-cache-config
shell: bash
env:
CACHE_KEY_SUFFIX: ${{ inputs.cache-key-suffix }}
LOCKFILE_HASH: ${{ hashFiles('pnpm-lock.yaml') }}
USE_ACTIONS_CACHE: ${{ inputs.use-actions-cache }}
USE_RESTORE_KEYS: ${{ inputs.use-restore-keys }}
run: |
set -euo pipefail
echo "enabled=$USE_ACTIONS_CACHE" >> "$GITHUB_OUTPUT"
echo "primary-key=${RUNNER_OS}-pnpm-store-${CACHE_KEY_SUFFIX}-${LOCKFILE_HASH}" >> "$GITHUB_OUTPUT"
if [ "$USE_RESTORE_KEYS" = "true" ]; then
echo "restore-keys=${RUNNER_OS}-pnpm-store-${CACHE_KEY_SUFFIX}-" >> "$GITHUB_OUTPUT"
else
echo "restore-keys=" >> "$GITHUB_OUTPUT"
fi
- name: Restore pnpm store cache
id: pnpm-cache-restore
if: inputs.use-actions-cache == 'true'
uses: actions/cache/restore@v5
- name: Restore pnpm store cache (exact key only)
if: inputs.use-actions-cache == 'true' && inputs.use-restore-keys != 'true'
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ steps.pnpm-cache-config.outputs.primary-key }}
restore-keys: ${{ steps.pnpm-cache-config.outputs.restore-keys }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Restore pnpm store cache (with fallback keys)
if: inputs.use-actions-cache == 'true' && inputs.use-restore-keys == 'true'
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-

View File

@@ -12,9 +12,6 @@ paths-ignore:
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"

View File

@@ -1,33 +0,0 @@
# OpenClaw Docs Agent
You are maintaining OpenClaw documentation after a main-branch commit.
Goal: inspect the code changes and existing documentation, then update existing docs only when they are stale, incomplete, or misleading.
Hard limits:
- Edit existing files only.
- Do not create new docs pages, images, assets, scripts, code files, or workflow files.
- Do not delete or rename files.
- Do not change production code, tests, package metadata, generated baselines, lockfiles, or CI config.
- Keep changes minimal and factual.
- Use "plugin/plugins" in user-facing docs/UI/changelog; `extensions/` is only the internal workspace layout.
- Do not add a changelog entry unless the docs update describes a user-facing behavior/API change from the triggering commit.
Allowed paths:
- `docs/**`
- `README.md`
- `CHANGELOG.md`
Required workflow:
1. Run `pnpm docs:list` if available and read relevant docs based on `read_when` hints.
2. Inspect the triggering event via `$GITHUB_EVENT_PATH`, then review `$DOCS_AGENT_BASE_SHA..$DOCS_AGENT_HEAD_SHA` and its changed files. If either env var is missing, fall back to the event payload.
3. Update stale existing documentation, if needed.
4. Run `pnpm check:docs` if dependencies are available.
5. Leave the worktree clean if no docs need changes.
If `pnpm docs:check-mdx` or `pnpm check:docs` reports MDX parse errors, fix only the syntax needed for the listed existing docs files. Preserve prose meaning, frontmatter, code fences, and links; do not broadly rewrite translated or source content while repairing parser failures.
When uncertain, prefer no edit and explain the uncertainty in the final message.

View File

@@ -1,25 +0,0 @@
# OpenClaw Docs MDX Repair Agent
You are repairing generated OpenClaw documentation after a fast MDX validation failure.
Goal: fix only the MDX syntax errors reported by the checker.
Hard limits:
- Edit only existing Markdown/MDX files under the locale path named by `LOCALE`.
- Do not edit source English docs unless `LOCALE=en`.
- Do not edit code, workflows, package metadata, generated sync metadata, translation memory, or assets.
- Do not add, delete, or rename files.
- Preserve the meaning of translated prose.
- Preserve frontmatter, `x-i18n.source_hash`, links, code fences, JSX component names, and existing page structure.
- Avoid broad formatting or retranslation.
Required workflow:
1. Read `.openclaw-sync/mdx/${LOCALE}.json` when it exists.
2. Inspect only the listed files and nearby lines.
3. Fix the minimal syntax issue, such as broken JSX attribute quoting, mismatched component closing tags, raw `<` text, raw HTML comments, or accidental top-level `import`/`export` text.
4. Run `node source/scripts/check-docs-mdx.mjs "docs/${LOCALE}" --json-out ".openclaw-sync/mdx/${LOCALE}.json"`.
5. Leave no changes outside `docs/${LOCALE}`.
When uncertain, prefer the smallest escaping fix: backticks for literal words, `&lt;` for literal `<`, double quotes around JSX attribute values, and balanced component tags.

View File

@@ -1,44 +0,0 @@
# OpenClaw Test Performance Agent
You are maintaining OpenClaw test performance after a trusted main-branch CI run.
Goal: inspect the full-suite test performance report, then make small, coverage-preserving improvements to slow tests when the fix is clear. If the baseline report shows failing tests and the fix is obvious, fix those too.
Inputs:
- Baseline grouped report: `.artifacts/test-perf/baseline-before.json`
- Per-config Vitest JSON reports: `.artifacts/test-perf/baseline-before/vitest-json/`
- Per-config logs: `.artifacts/test-perf/baseline-before/logs/`
Hard limits:
- Preserve test coverage and behavioral intent.
- Do not delete, skip, weaken, or narrow test cases to make the suite faster.
- Do not add `test.skip`, `it.skip`, `describe.skip`, `test.only`, `it.only`, or `describe.only`.
- Do not update snapshots, generated baselines, inventories, ignore files, lockfiles, package metadata, CI workflows, or release metadata.
- Do not add dependencies.
- Do not create, delete, or rename files.
- Do not do broad refactors or style-only rewrites.
- Keep changes minimal and focused on the slow or failing tests you can justify from the report.
- Prefer no edit when a performance improvement is speculative.
- If `.artifacts/test-perf/baseline-before.json` has `"failed": true`, do not make performance-only edits. First inspect the failed config logs. Edit only when the test failure has an obvious, coverage-preserving fix. If no obvious failure fix exists, leave the worktree clean.
Good fixes:
- Replace broad partial module mocks, especially `importOriginal()` mocks, with narrow injected dependencies or local runtime seams.
- Avoid importing heavy barrels in hot tests when a narrow module or helper covers the same behavior.
- Add or adjust a production lazy/injection seam only when that is the narrowest way to preserve coverage while removing expensive imports or fixing an obvious mock/import failure.
- Move expensive setup from per-test hooks to shared setup only when state isolation remains correct.
- Reuse existing fixtures/builders instead of recreating expensive work per case.
- Mock expensive runtime boundaries directly: filesystem crawls, package registries, provider SDKs, network/process launch, browser/runtime scanners.
- Keep one integration smoke per boundary and test pure helpers directly, but only when the same behavior remains covered.
Required workflow:
1. Run `pnpm docs:list` if available, then read `docs/reference/test.md` and `docs/help/testing.md` sections about test performance.
2. Inspect `.artifacts/test-perf/baseline-before.json`. If `failed` is true, inspect the failed config logs before looking at slow files.
3. Pick at most a few low-risk files. When baseline failed, pick only files needed for the obvious failure fix; otherwise focus on the slowest files/configs. Explain the coverage-preserving reason in comments only if the code would otherwise be unclear.
4. Run targeted tests for changed files where possible. Use `pnpm test <path>` and optionally `pnpm test:perf:imports <path>`.
5. Leave the worktree clean if no safe improvement exists.
When uncertain, make no edit and explain the uncertainty in the final message.

44
.github/labeler.yml vendored
View File

@@ -3,12 +3,6 @@
- any-glob-to-any-file:
- "extensions/bluebubbles/**"
- "docs/channels/bluebubbles.md"
"plugin: azure-speech":
- changed-files:
- any-glob-to-any-file:
- "extensions/azure-speech/**"
- "docs/providers/azure-speech.md"
- "docs/tools/tts.md"
"channel: discord":
- changed-files:
- any-glob-to-any-file:
@@ -30,16 +24,6 @@
- any-glob-to-any-file:
- "extensions/googlechat/**"
- "docs/channels/googlechat.md"
"plugin: google-meet":
- changed-files:
- any-glob-to-any-file:
- "extensions/google-meet/**"
- "docs/plugins/google-meet.md"
"plugin: bonjour":
- changed-files:
- any-glob-to-any-file:
- "extensions/bonjour/**"
- "docs/gateway/bonjour.md"
"channel: imessage":
- changed-files:
- any-glob-to-any-file:
@@ -233,10 +217,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/diagnostics-otel/**"
"extensions: diagnostics-prometheus":
- changed-files:
- any-glob-to-any-file:
- "extensions/diagnostics-prometheus/**"
"extensions: llm-task":
- changed-files:
- any-glob-to-any-file:
@@ -317,11 +297,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/huggingface/**"
"extensions: inworld":
- changed-files:
- any-glob-to-any-file:
- "extensions/inworld/**"
- "docs/providers/inworld.md"
"extensions: kilocode":
- changed-files:
- any-glob-to-any-file:
@@ -330,11 +305,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/lmstudio/**"
"extensions: litellm":
- changed-files:
- any-glob-to-any-file:
- "extensions/litellm/**"
- "docs/providers/litellm.md"
"extensions: openai":
- changed-files:
- any-glob-to-any-file:
@@ -371,11 +341,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/qianfan/**"
"extensions: senseaudio":
- changed-files:
- any-glob-to-any-file:
- "extensions/senseaudio/**"
- "docs/providers/senseaudio.md"
"extensions: synthetic":
- changed-files:
- any-glob-to-any-file:
@@ -392,11 +357,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/together/**"
"extensions: tts-local-cli":
- changed-files:
- any-glob-to-any-file:
- "extensions/tts-local-cli/**"
- "docs/tools/tts.md"
"extensions: venice":
- changed-files:
- any-glob-to-any-file:
@@ -417,7 +377,3 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/fal/**"
"extensions: gradium":
- changed-files:
- any-glob-to-any-file:
- "extensions/gradium/**"

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -5,8 +5,8 @@ on:
types: [opened, edited, labeled]
issue_comment:
types: [created]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; trusted base checkout only, no untrusted PR code execution
types: [opened, edited, synchronize, reopened, labeled]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
types: [labeled]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -20,15 +20,10 @@ permissions: {}
jobs:
auto-response:
permissions:
contents: read
issues: write
pull-requests: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
@@ -41,15 +36,499 @@ jobs:
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Run Barnacle auto-response
- name: Handle labeled items
uses: actions/github-script@v9
with:
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
const { pathToFileURL } = require("node:url");
const moduleUrl = pathToFileURL(
`${process.env.GITHUB_WORKSPACE}/scripts/github/barnacle-auto-response.mjs`,
);
const { runBarnacleAutoResponse } = await import(moduleUrl.href);
// Labels prefixed with "r:" are auto-response triggers.
const activePrLimit = 10;
const rules = [
{
label: "r: skill",
close: true,
message:
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
},
{
label: "r: support",
close: true,
message:
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: no-ci-pr",
close: true,
message:
"Please don't make PRs for test failures on main.\n\n" +
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
"Thank you.",
},
{
label: "r: too-many-prs",
close: true,
message:
`Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` +
"Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.",
},
{
label: "r: testflight",
close: true,
commentTriggers: ["testflight"],
message: "Not available, build from source.",
},
{
label: "r: third-party-extension",
close: true,
message:
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
},
{
label: "r: moltbook",
close: true,
lock: true,
lockReason: "off-topic",
commentTriggers: ["moltbook"],
message:
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
},
];
await runBarnacleAutoResponse({ github, context, core });
const maintainerTeam = "maintainer";
const pingWarningMessage =
"Please dont spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
const mentionRegex = /@([A-Za-z0-9-]+)/g;
const maintainerCache = new Map();
const normalizeLogin = (login) => login.toLowerCase();
const bugSubtypeLabelSpecs = {
regression: {
color: "D93F0B",
description: "Behavior that previously worked and now fails",
},
"bug:crash": {
color: "B60205",
description: "Process/app exits unexpectedly or hangs",
},
"bug:behavior": {
color: "D73A4A",
description: "Incorrect behavior without a crash",
},
};
const bugTypeToLabel = {
"Regression (worked before, now fails)": "regression",
"Crash (process/app exits or hangs)": "bug:crash",
"Behavior bug (incorrect output/state without crash)": "bug:behavior",
};
const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);
const extractIssueFormValue = (body, field) => {
if (!body) {
return "";
}
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
"i",
);
const match = body.match(regex);
if (!match) {
return "";
}
for (const line of match[1].split("\n")) {
const trimmed = line.trim();
if (trimmed) {
return trimmed;
}
}
return "";
};
const ensureLabelExists = async (name, color, description) => {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description,
});
}
};
const syncBugSubtypeLabel = async (issue, labelSet) => {
if (!labelSet.has("bug")) {
return;
}
const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
const targetLabel = bugTypeToLabel[selectedBugType];
if (!targetLabel) {
return;
}
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
for (const subtypeLabel of bugSubtypeLabels) {
if (subtypeLabel === targetLabel) {
continue;
}
if (!labelSet.has(subtypeLabel)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: subtypeLabel,
});
labelSet.delete(subtypeLabel);
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
if (!labelSet.has(targetLabel)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [targetLabel],
});
labelSet.add(targetLabel);
}
};
const isMaintainer = async (login) => {
if (!login) {
return false;
}
const normalized = normalizeLogin(login);
if (maintainerCache.has(normalized)) {
return maintainerCache.get(normalized);
}
let isMember = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: maintainerTeam,
username: normalized,
});
isMember = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
maintainerCache.set(normalized, isMember);
return isMember;
};
const countMaintainerMentions = async (body, authorLogin) => {
if (!body) {
return 0;
}
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
return 0;
}
const haystack = body.toLowerCase();
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
if (haystack.includes(teamMention)) {
return 3;
}
const mentions = new Set();
for (const match of body.matchAll(mentionRegex)) {
mentions.add(normalizeLogin(match[1]));
}
if (normalizedAuthor) {
mentions.delete(normalizedAuthor);
}
let count = 0;
for (const login of mentions) {
if (await isMaintainer(login)) {
count += 1;
}
}
return count;
};
const triggerLabel = "trigger-response";
const activePrLimitLabel = "r: too-many-prs";
const activePrLimitOverrideLabel = "r: too-many-prs-override";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
return;
}
const labelSet = new Set(
(target.labels ?? [])
.map((label) => (typeof label === "string" ? label : label?.name))
.filter((name) => typeof name === "string"),
);
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const comment = context.payload.comment;
if (comment) {
const authorLogin = comment.user?.login ?? "";
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
return;
}
const commentBody = comment.body ?? "";
const responses = [];
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
if (mentionCount >= 3) {
responses.push(pingWarningMessage);
}
const commentHaystack = commentBody.toLowerCase();
const commentRule = rules.find((item) =>
(item.commentTriggers ?? []).some((trigger) =>
commentHaystack.includes(trigger),
),
);
if (commentRule) {
responses.push(commentRule.message);
}
if (responses.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
body: responses.join("\n\n"),
});
}
return;
}
if (issue) {
const action = context.payload.action;
if (action === "opened" || action === "edited") {
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
const authorLogin = issue.user?.login ?? "";
const mentionCount = await countMaintainerMentions(
issueText,
authorLogin,
);
if (mentionCount >= 3) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: pingWarningMessage,
});
}
await syncBugSubtypeLabel(issue, labelSet);
}
}
const hasTriggerLabel = labelSet.has(triggerLabel);
if (hasTriggerLabel) {
labelSet.delete(triggerLabel);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
name: triggerLabel,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
const isLabelEvent = context.payload.action === "labeled";
if (!hasTriggerLabel && !isLabelEvent) {
return;
}
if (issue) {
const title = issue.title ?? "";
const body = issue.body ?? "";
const haystack = `${title}\n${body}`.toLowerCase();
const hasMoltbookLabel = labelSet.has("r: moltbook");
const hasTestflightLabel = labelSet.has("r: testflight");
const hasSecurityLabel = labelSet.has("security");
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["security"],
});
labelSet.add("security");
}
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["r: testflight"],
});
labelSet.add("r: testflight");
}
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["r: moltbook"],
});
labelSet.add("r: moltbook");
}
}
const invalidLabel = "invalid";
const spamLabel = "r: spam";
const dirtyLabel = "dirty";
const badBarnacleLabel = "bad-barnacle";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
if (pullRequest) {
if (labelSet.has(badBarnacleLabel)) {
core.info(`Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`);
return;
}
if (labelSet.has(dirtyLabel)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
const labelCount = labelSet.size;
if (labelCount > 20) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
if (labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
lock_reason: "spam",
});
return;
}
if (labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
}
if (issue && labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
state_reason: "not_planned",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: "spam",
});
return;
}
if (issue && labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
state_reason: "not_planned",
});
return;
}
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
labelSet.delete(activePrLimitLabel);
}
const rule = rules.find((item) => labelSet.has(item.label));
if (!rule) {
return;
}
const issueNumber = target.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: rule.message,
});
if (rule.close) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: "closed",
});
}
if (rule.lock) {
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
lock_reason: rule.lockReason ?? "resolved",
});
}

View File

@@ -62,18 +62,18 @@ jobs:
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
echo "checkout attempt ${attempt}/2 succeeded"
}
for attempt in 1 2 3 4 5; do
for attempt in 1 2; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
echo "checkout attempt ${attempt}/2 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
echo "checkout failed after 2 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ jobs:
matrix:
include:
- language: javascript-typescript
runs_on: blacksmith-32vcpu-ubuntu-2404
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: true
needs_python: false
needs_java: false

View File

@@ -49,7 +49,7 @@ jobs:
run: |
set -euo pipefail
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","tr","uk","id","pl","th"]'
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","tr","uk","id","pl"]'
if [ "$EVENT_NAME" != "push" ]; then
echo "has_locales=true" >> "$GITHUB_OUTPUT"
@@ -137,7 +137,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
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

View File

@@ -1,251 +0,0 @@
name: Docs Agent
on:
workflow_run: # zizmor: ignore[dangerous-triggers] main-only docs repair after trusted CI; job gates repository, event, branch, actor, conclusion, exact current main SHA, and hourly cadence before using write token
workflows:
- CI
types:
- completed
workflow_dispatch:
permissions:
actions: read
contents: write
concurrency:
group: docs-agent-main
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
update-docs:
if: >
github.repository == 'openclaw/openclaw' &&
github.actor != 'github-actions[bot]' &&
(github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.actor.login != 'github-actions[bot]'))
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Gate trusted main activity and hourly cadence
id: gate
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "workflow_run" ]; then
head_sha="$(git rev-parse HEAD)"
review_base="$(git rev-parse "${head_sha}^" 2>/dev/null || printf '%s' "$head_sha")"
{
echo "run_agent=true"
echo "base_sha=${head_sha}"
echo "review_base_sha=${review_base}"
echo "review_head_sha=${head_sha}"
} >> "$GITHUB_OUTPUT"
exit 0
fi
for attempt in 1 2 3 4 5; do
if git fetch --no-tags origin main; then
break
fi
if [ "$attempt" = "5" ]; then
echo "Failed to fetch main after retries." >&2
exit 1
fi
echo "Fetch attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
remote_main="$(git rev-parse origin/main)"
if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then
echo "CI run is superseded by ${remote_main}; skipping docs agent for ${WORKFLOW_HEAD_SHA}."
echo "run_agent=false" >> "$GITHUB_OUTPUT"
exit 0
fi
runs_json="$RUNNER_TEMP/docs-agent-runs.json"
gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/docs-agent.yml/runs" \
-f branch=main \
-f event=workflow_run \
-f per_page=100 > "$runs_json"
one_hour_ago="$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)"
recent_runs="$(
jq -r \
--argjson current_run_id "$GITHUB_RUN_ID" \
--arg one_hour_ago "$one_hour_ago" \
'.workflow_runs[]
| select(.database_id != $current_run_id)
| select(.created_at >= $one_hour_ago)
| select(.status != "cancelled")
| select((.conclusion // "") != "skipped")
| [.database_id, .status, (.conclusion // ""), .created_at, .head_sha]
| @tsv' "$runs_json"
)"
if [ -n "$recent_runs" ]; then
echo "Docs agent already ran or is running within the last hour; skipping."
printf '%s\n' "$recent_runs"
echo "run_agent=false" >> "$GITHUB_OUTPUT"
exit 0
fi
review_base="$(
jq -r \
--argjson current_run_id "$GITHUB_RUN_ID" \
--arg remote_main "$remote_main" \
'.workflow_runs[]
| select(.database_id != $current_run_id)
| select(.status != "cancelled")
| select((.conclusion // "") != "skipped")
| .head_sha
| select(. != null and . != "")
| select(. != $remote_main)
' "$runs_json" | head -n 1
)"
if [ -z "$review_base" ] || ! git cat-file -e "${review_base}^{commit}" 2>/dev/null; then
review_base="$(git rev-parse "${remote_main}^" 2>/dev/null || printf '%s' "$remote_main")"
fi
{
echo "run_agent=true"
echo "base_sha=${remote_main}"
echo "review_base_sha=${review_base}"
echo "review_head_sha=${remote_main}"
} >> "$GITHUB_OUTPUT"
- name: Setup Node environment
if: steps.gate.outputs.run_agent == 'true'
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Ensure docs agent key exists
if: steps.gate.outputs.run_agent == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}
with:
openai-api-key: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/docs-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: medium
sandbox: workspace-write
safety-strategy: drop-sudo
codex-args: '["--full-auto"]'
- name: Enforce existing-docs-only patch
if: steps.gate.outputs.run_agent == 'true'
run: |
set -euo pipefail
untracked="$(git ls-files --others --exclude-standard)"
if [ -n "$untracked" ]; then
echo "Docs agent created untracked files; forbidden:"
printf '%s\n' "$untracked"
exit 1
fi
added_or_deleted="$(git diff --name-status --diff-filter=AD)"
if [ -n "$added_or_deleted" ]; then
echo "Docs agent added or deleted tracked files; forbidden:"
printf '%s\n' "$added_or_deleted"
exit 1
fi
bad_paths="$(
git diff --name-only | while IFS= read -r path; do
case "$path" in
docs/*|README.md|CHANGELOG.md) ;;
*) printf '%s\n' "$path" ;;
esac
done
)"
if [ -n "$bad_paths" ]; then
echo "Docs agent touched non-doc paths; forbidden:"
printf '%s\n' "$bad_paths"
exit 1
fi
- name: Restore Node 24 path
if: steps.gate.outputs.run_agent == 'true'
run:
| # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
set -euo pipefail
export PATH="${NODE_BIN}:${PATH}"
echo "${NODE_BIN}" >> "$GITHUB_PATH"
node -v
corepack enable
pnpm -v
- name: Check docs
if: steps.gate.outputs.run_agent == 'true'
run: pnpm check:docs
- name: Commit docs updates
if: steps.gate.outputs.run_agent == 'true'
env:
BASE_SHA: ${{ steps.gate.outputs.base_sha }}
GITHUB_TOKEN: ${{ github.token }}
TARGET_BRANCH: main
run: |
set -euo pipefail
if git diff --quiet; then
echo "No docs changes."
exit 0
fi
git config user.name "openclaw-docs-agent[bot]"
git config user.email "openclaw-docs-agent[bot]@users.noreply.github.com"
git add docs README.md CHANGELOG.md
git commit --no-verify -m "docs: refresh documentation"
for attempt in 1 2 3 4 5; do
if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then
echo "Fetch attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
continue
fi
if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")"
if [ "$remote_main" != "$BASE_SHA" ]; then
echo "main advanced from ${BASE_SHA} to ${remote_main}; skipping stale docs update."
exit 0
fi
echo "Docs update attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push docs updates after retries." >&2
exit 1

View File

@@ -32,19 +32,9 @@ jobs:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
run: |
set -euo pipefail
for attempt in 1 2 3 4 5; do
rm -rf publish
if git clone \
"https://x-access-token:${OPENCLAW_DOCS_SYNC_TOKEN}@github.com/openclaw/docs.git" \
publish; then
exit 0
fi
echo "Clone attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to clone publish repo after retries." >&2
exit 1
git clone \
"https://x-access-token:${OPENCLAW_DOCS_SYNC_TOKEN}@github.com/openclaw/docs.git" \
publish
- name: Sync docs into publish repo
run: |
@@ -53,56 +43,26 @@ jobs:
--source-repo "$GITHUB_REPOSITORY" \
--source-sha "$GITHUB_SHA"
- name: Install docs MDX checker dependency
run: npm install --no-save --package-lock=false @mdx-js/mdx@3.1.1
- name: Check publish docs MDX
run: node "$GITHUB_WORKSPACE/publish/.openclaw-sync/check-docs-mdx.mjs" "$GITHUB_WORKSPACE/publish/docs"
- name: Commit publish repo sync
working-directory: publish
run: |
set -euo pipefail
remote_source_sha() {
git show refs/remotes/origin/main:.openclaw-sync/source.json 2>/dev/null \
| node -e 'const fs = require("node:fs"); try { const data = JSON.parse(fs.readFileSync(0, "utf8")); if (data.sha) process.stdout.write(data.sha); } catch {}' \
|| true
}
skip_stale_source() {
current_source_sha="$(remote_source_sha)"
if [ -z "$current_source_sha" ] || [ "$current_source_sha" = "$GITHUB_SHA" ]; then
return
fi
if git -C "$GITHUB_WORKSPACE" merge-base --is-ancestor "$GITHUB_SHA" "$current_source_sha"; then
echo "Skipping stale publish sync for $GITHUB_SHA; origin/main already mirrors $current_source_sha."
exit 0
fi
}
if git diff --quiet -- docs .openclaw-sync; then
echo "No publish-repo changes."
exit 0
fi
if git fetch origin main:refs/remotes/origin/main; then
skip_stale_source
fi
git config user.name "openclaw-docs-sync[bot]"
git config user.email "openclaw-docs-sync[bot]@users.noreply.github.com"
git add docs .openclaw-sync
git commit -m "chore(sync): mirror docs from $GITHUB_REPOSITORY@$GITHUB_SHA"
for attempt in 1 2 3 4 5; do
if git fetch origin main:refs/remotes/origin/main; then
skip_stale_source
if git rebase -X theirs origin/main && git push origin HEAD:main; then
exit 0
fi
git fetch origin main
git rebase origin/main
if git push origin HEAD:main; then
exit 0
fi
git rebase --abort >/dev/null 2>&1 || true
echo "Publish sync attempt ${attempt} failed; retrying."
echo "Push attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done

View File

@@ -31,8 +31,7 @@ jobs:
translate-tr-release \
translate-uk-release \
translate-id-release \
translate-pl-release \
translate-th-release
translate-pl-release
do
gh api repos/openclaw/docs/dispatches \
--method POST \

View File

@@ -1,39 +0,0 @@
name: Docs
on:
push:
branches: [main]
paths:
- "**/*.md"
- "docs/**"
permissions:
contents: read
concurrency:
group: ${{ format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
docs:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check docs
run: pnpm check:docs

View File

@@ -1,59 +0,0 @@
name: Duplicate PRs After Merge
on:
workflow_dispatch:
inputs:
landed_pr:
description: "Merged PR number that supersedes the duplicates"
required: true
type: string
duplicate_prs:
description: "Comma or whitespace separated duplicate PR numbers to close"
required: true
type: string
apply:
description: "When true, label/comment/close; otherwise dry-run only"
required: true
type: boolean
default: false
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: duplicate-after-merge-${{ github.event.inputs.landed_pr }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
GH_TOKEN: ${{ github.token }}
jobs:
close-duplicates:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Close confirmed duplicates
env:
APPLY: ${{ inputs.apply }}
DUPLICATE_PRS: ${{ inputs.duplicate_prs }}
LANDED_PR: ${{ inputs.landed_pr }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
args=(
--repo "$REPO"
--landed-pr "$LANDED_PR"
--duplicates "$DUPLICATE_PRS"
)
if [[ "$APPLY" == "true" ]]; then
args+=(--apply)
fi
node scripts/close-duplicate-prs-after-merge.mjs "${args[@]}"

View File

@@ -1,32 +1,17 @@
name: Install Smoke
on:
schedule:
- cron: "17 3 * * *"
push:
branches: [main]
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
workflow_dispatch:
inputs:
run_bun_global_install_smoke:
description: Run the Bun global install image-provider smoke
required: false
default: false
type: boolean
workflow_call:
inputs:
ref:
description: Git ref to validate
required: false
type: string
run_bun_global_install_smoke:
description: Run the Bun global install image-provider smoke
required: false
default: true
type: boolean
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-{1}', github.workflow, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }}
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
env:
@@ -34,162 +19,65 @@ env:
jobs:
preflight:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
outputs:
docs_only: ${{ steps.manifest.outputs.docs_only }}
run_install_smoke: ${{ steps.manifest.outputs.run_install_smoke }}
run_fast_install_smoke: ${{ steps.manifest.outputs.run_fast_install_smoke }}
run_full_install_smoke: ${{ steps.manifest.outputs.run_full_install_smoke }}
run_bun_global_install_smoke: ${{ steps.manifest.outputs.run_bun_global_install_smoke }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure preflight base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: docs_scope
uses: ./.github/actions/detect-docs-changes
- name: Detect changed smoke scope
id: changed_scope
if: steps.docs_scope.outputs.docs_only != 'true'
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
- name: Build install-smoke CI manifest
id: manifest
env:
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE: ${{ inputs.run_bun_global_install_smoke || 'false' }}
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
run: |
event_name="${OPENCLAW_CI_EVENT_NAME:-}"
workflow_bun_global_install_smoke="${OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:-false}"
docs_only=false
run_fast_install_smoke=true
run_full_install_smoke=true
run_bun_global_install_smoke=false
run_install_smoke=true
if [ "$event_name" = "schedule" ]; then
run_bun_global_install_smoke=true
elif [ "$event_name" = "workflow_dispatch" ] || [ "$event_name" = "workflow_call" ]; then
if [ "$workflow_bun_global_install_smoke" = "true" ]; then
run_bun_global_install_smoke=true
fi
docs_only="${OPENCLAW_CI_DOCS_ONLY:-false}"
run_changed_smoke="${OPENCLAW_CI_RUN_CHANGED_SMOKE:-false}"
run_install_smoke=false
if [ "$docs_only" != "true" ] && [ "$run_changed_smoke" = "true" ]; then
run_install_smoke=true
fi
{
echo "docs_only=$docs_only"
echo "run_install_smoke=$run_install_smoke"
echo "run_fast_install_smoke=$run_fast_install_smoke"
echo "run_full_install_smoke=$run_full_install_smoke"
echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"
} >> "$GITHUB_OUTPUT"
install-smoke-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' && needs.preflight.outputs.run_full_install_smoke != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout CLI
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_DOCKER_APT_UPGRADE=0
OPENCLAW_EXTENSIONS=matrix
tags: |
openclaw-dockerfile-smoke:local
openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Run root Dockerfile CLI smoke
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const fs = require(\"node:fs\");
const path = require(\"node:path\");
const pkg = require(\"/app/package.json\");
for (const [dep, rel] of Object.entries(pkg.pnpm?.patchedDependencies ?? {})) {
const absolute = path.join(\"/app\", rel);
if (!fs.existsSync(absolute)) {
throw new Error(`missing patch for ${dep}: ${rel}`);
}
}
"
'
- name: Run agents delete shared workspace Docker CLI smoke
env:
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
install-smoke:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
@@ -197,8 +85,6 @@ jobs:
steps:
- name: Checkout CLI
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
@@ -210,8 +96,6 @@ jobs:
OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1"
run: bash scripts/e2e/qr-import-docker.sh
# Build once with the matrix extension and tag both smoke names. This
# keeps the build-arg coverage without a second Blacksmith build action.
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
@@ -219,10 +103,7 @@ jobs:
file: ./Dockerfile
build-args: |
OPENCLAW_DOCKER_APT_UPGRADE=0
OPENCLAW_EXTENSIONS=matrix
tags: |
openclaw-dockerfile-smoke:local
openclaw-ext-smoke:local
tags: openclaw-dockerfile-smoke:local
load: true
push: false
provenance: false
@@ -231,18 +112,28 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run agents delete shared workspace Docker CLI smoke
env:
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_DOCKER_APT_UPGRADE=0
OPENCLAW_EXTENSIONS=matrix
tags: openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
@@ -299,6 +190,7 @@ jobs:
provenance: false
- name: Build installer non-root image
if: github.event_name != 'pull_request'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: ./scripts/docker
@@ -308,19 +200,12 @@ jobs:
push: false
provenance: false
- name: Setup Node environment for installer smoke
- name: Setup Node environment for local pack smoke
uses: ./.github/actions/setup-node-env
with:
install-bun: ${{ needs.preflight.outputs.run_bun_global_install_smoke }}
install-bun: "false"
install-deps: "true"
- name: Run Bun global install image-provider smoke
if: needs.preflight.outputs.run_bun_global_install_smoke == 'true'
env:
OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD: "0"
run: bash scripts/e2e/bun-global-install-smoke.sh
- name: Run installer docker tests
env:
OPENCLAW_INSTALL_URL: https://openclaw.ai/install.sh
@@ -328,18 +213,16 @@ jobs:
OPENCLAW_NO_ONBOARD: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: "0"
OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: latest
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
run: bash scripts/test-install-sh-docker.sh
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 8
env:
@@ -348,8 +231,6 @@ jobs:
steps:
- name: Checkout CLI
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
@@ -363,5 +244,4 @@ jobs:
- name: Run fast bundled plugin Docker E2E
env:
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s
run: timeout 240s pnpm test:docker:bundled-channel-deps:fast
run: timeout 120s pnpm test:docker:bundled-channel-deps:fast

View File

@@ -1,167 +0,0 @@
name: NPM Telegram Beta E2E
on:
workflow_dispatch:
inputs:
package_spec:
description: Published OpenClaw package spec to test
required: true
default: openclaw@beta
type: string
provider_mode:
description: QA provider mode
required: true
default: mock-openai
type: choice
options:
- mock-openai
- live-frontier
scenario:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
permissions:
contents: read
concurrency:
group: npm-telegram-beta-e2e-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
jobs:
validate_dispatch_ref:
name: Validate dispatch ref
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Require main workflow ref
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "NPM Telegram beta E2E must be dispatched from main so workflow logic stays controlled." >&2
exit 1
fi
approve_release_manager:
name: Approve npm Telegram beta E2E
needs: validate_dispatch_ref
runs-on: ubuntu-latest
environment: npm-release
steps:
- name: Record approval
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
run: echo "Approved npm Telegram beta E2E for ${PACKAGE_SPEC}"
run_npm_telegram_beta_e2e:
name: Run published npm Telegram E2E
needs: approve_release_manager
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
permissions:
contents: read
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Build Docker E2E image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: build
platforms: linux/amd64
tags: openclaw-docker-e2e:local
load: true
push: false
provenance: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate inputs and secrets
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
exit 1
fi
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
if [[ "${PROVIDER_MODE}" == "live-frontier" ]]; then
require_var OPENAI_API_KEY
fi
- name: Run npm Telegram beta E2E
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_DOCKER_E2E_IMAGE: openclaw-docker-e2e:local
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.package_spec }}
OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE: convex
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/npm-telegram-beta-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
export OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="${output_dir}"
if [[ -n "${INPUT_SCENARIO// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_SCENARIOS="${INPUT_SCENARIO}"
fi
pnpm test:docker:npm-telegram-live
- name: Upload npm Telegram E2E artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn

View File

@@ -432,35 +432,24 @@ jobs:
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
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 }}
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
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 \
--candidate-tgz "${CANDIDATE_TGZ}" \
--candidate-version "${CANDIDATE_VERSION}" \
--source-sha "${SOURCE_SHA}" \
--baseline-spec "${BASELINE_SPEC}" \
--previous-version "${PREVIOUS_VERSION}" \
--baseline-tgz "${BASELINE_TGZ}" \
--provider "${PROVIDER}" \
--mode "${MODE}" \
--suite "${SUITE}" \
--ref "${REF}" \
--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 "${OUTPUT_DIR}"
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}"
- name: Summarize release checks
if: always()

View File

@@ -28,11 +28,6 @@ on:
required: false
default: true
type: boolean
live_models_only:
description: Whether to run only the Docker live model matrix when live suites are enabled
required: false
default: false
type: boolean
workflow_call:
inputs:
ref:
@@ -59,11 +54,6 @@ on:
required: false
default: true
type: boolean
live_models_only:
description: Whether to run only the Docker live model matrix when live suites are enabled
required: false
default: false
type: boolean
secrets:
OPENAI_API_KEY:
required: false
@@ -151,12 +141,9 @@ on:
required: false
OPENCLAW_GEMINI_SETTINGS_JSON:
required: false
FIREWORKS_API_KEY:
required: false
permissions:
contents: read
packages: write
pull-requests: read
env:
@@ -166,7 +153,7 @@ env:
jobs:
validate_selected_ref:
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_sha: ${{ steps.validate.outputs.selected_sha }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
@@ -222,8 +209,8 @@ jobs:
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only
runs-on: blacksmith-8vcpu-ubuntu-2404
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -288,7 +275,7 @@ jobs:
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e || (inputs.include_live_suites && !inputs.live_models_only)
if: inputs.include_repo_e2e || inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -362,8 +349,8 @@ jobs:
run: ${{ matrix.command }}
validate_docker_e2e:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites
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:
@@ -375,76 +362,55 @@ jobs:
command: pnpm test:docker:onboard
timeout_minutes: 60
release_path: true
- suite_id: docker-npm-onboard-channel-agent
label: Npm Onboard Channel Agent Docker E2E
command: pnpm test:docker:npm-onboard-channel-agent
timeout_minutes: 90
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
- suite_id: docker-openai-web-search-minimal
label: OpenAI Web Search Minimal Docker E2E
command: pnpm test:docker:openai-web-search-minimal
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
- suite_id: docker-pi-bundle-mcp-tools
label: Pi Bundle MCP Tools Docker E2E
command: pnpm test:docker:pi-bundle-mcp-tools
timeout_minutes: 60
release_path: true
- suite_id: docker-cron-mcp-cleanup
label: Cron MCP Cleanup Docker E2E
command: pnpm test:docker:cron-mcp-cleanup
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
- suite_id: docker-plugin-update
label: Plugin Update Docker E2E
command: pnpm test:docker:plugin-update
timeout_minutes: 60
release_path: true
- suite_id: docker-config-reload
label: Config Reload Docker E2E
command: pnpm test:docker:config-reload
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-bundled-channel-deps
label: Bundled Channel Runtime Deps Docker E2E
command: pnpm test:docker:bundled-channel-deps
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
- suite_id: docker-session-runtime-context
label: Session Runtime Context Docker E2E
command: pnpm test:docker:session-runtime-context
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 }}
@@ -489,9 +455,6 @@ jobs:
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 }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_SKIP_DOCKER_BUILD: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -499,13 +462,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -541,229 +497,23 @@ jobs:
exit 1
fi
;;
esac
- name: Run ${{ matrix.label }}
run: ${{ matrix.command }}
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 75
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_SKIP_DOCKER_BUILD: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate Open WebUI credentials
shell: bash
run: |
set -euo pipefail
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
exit 1
}
- name: Run Open WebUI Docker E2E
run: pnpm test:docker:openwebui
prepare_docker_e2e_image:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image.outputs.image }}
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Resolve shared Docker E2E image tag
id: image
shell: bash
env:
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
image="ghcr.io/${repository}-docker-e2e:${SELECTED_SHA}"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "Shared Docker E2E image: \`$image\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Docker builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Build and push shared Docker E2E image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./scripts/e2e/Dockerfile
target: build
platforms: linux/amd64
cache-from: type=gha,scope=docker-e2e
cache-to: type=gha,mode=max,scope=docker-e2e
tags: ${{ steps.image.outputs.image }}
provenance: false
push: true
validate_live_models_docker:
name: Docker live models (${{ matrix.provider_label }})
needs: validate_selected_ref
if: inputs.include_live_suites
runs-on: ubuntu-24.04
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
include:
- provider_label: Anthropic
providers: anthropic
- provider_label: Google
providers: google
- provider_label: MiniMax
providers: minimax
- provider_label: OpenAI
providers: openai
- provider_label: OpenCode
providers: opencode-go
- provider_label: OpenRouter
providers: openrouter
- provider_label: xAI
providers: xai
- provider_label: Z.ai
providers: zai
- provider_label: Fireworks
providers: fireworks
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 }}
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 }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_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 }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
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: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Validate provider credential
shell: bash
run: |
set -euo pipefail
require_any() {
local label="$1"
shift
local key
for key in "$@"; do
if [[ -n "${!key:-}" ]]; then
return 0
fi
done
echo "Missing credential for ${label}: expected one of $*" >&2
exit 1
}
case "${{ matrix.providers }}" in
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
xai) require_any xAI XAI_API_KEY ;;
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
*)
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
exit 1
docker-openwebui)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
exit 1
}
;;
esac
- name: Run Docker live model sweep
run: pnpm test:docker:live-models
- 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 && !inputs.live_models_only
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -775,6 +525,11 @@ jobs:
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
@@ -839,7 +594,6 @@ jobs:
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 }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: ""
OPENCLAW_LIVE_VYDRA_VIDEO: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -869,7 +623,7 @@ jobs:
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5" >> "$GITHUB_ENV"
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

View File

@@ -43,7 +43,7 @@ jobs:
# so this public workflow can stay focused on OIDC publish only.
preflight_openclaw_npm:
if: ${{ inputs.preflight_only }}
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
permissions:
contents: read
steps:
@@ -252,7 +252,7 @@ jobs:
validate_publish_request:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
permissions:
contents: read
steps:

View File

@@ -32,9 +32,6 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
jobs:
resolve_target:
@@ -124,18 +121,9 @@ jobs:
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, install smoke, QA Lab parity, Matrix, and Telegram lanes, and the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
echo "- This run will execute cross-OS release validation plus the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
} >> "$GITHUB_STEP_SUMMARY"
install_smoke_release_checks:
needs: [resolve_target]
permissions:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
run_bun_global_install_smoke: true
cross_os_release_checks:
needs: [resolve_target]
permissions: read-all
@@ -156,7 +144,6 @@ jobs:
needs: [resolve_target]
permissions:
contents: read
packages: write
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
@@ -209,232 +196,3 @@ jobs:
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 }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
qa_lab_parity_release_checks:
name: Run QA Lab parity gate
needs: [resolve_target]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
OPENCLAW_LIVE_OPENAI_KEY: ""
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
OPENCLAW_LIVE_GEMINI_KEY: ""
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Build private QA runtime
run: pnpm build
- name: Run OpenAI candidate lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model openai/gpt-5.4-alt \
--output-dir .artifacts/qa-e2e/gpt54
- name: Run Opus 4.6 lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model anthropic/claude-opus-4-6 \
--alt-model anthropic/claude-sonnet-4-6 \
--output-dir .artifacts/qa-e2e/opus46
- name: Generate parity report
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-6 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.sha }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
environment: qa-live-shared
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
run: pnpm build
- name: Run Matrix live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/matrix-live-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa matrix \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--fast
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.sha }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
qa_live_telegram_release_checks:
name: Run QA Lab live Telegram lane
needs: [resolve_target]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
environment: qa-live-shared
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENAI_API_KEY
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run Telegram live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/telegram-live-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa telegram \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.sha }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn

View File

@@ -7,7 +7,6 @@ on:
permissions:
contents: read
packages: write
pull-requests: read
concurrency:
@@ -21,13 +20,12 @@ jobs:
live_and_openwebui_checks:
permissions:
contents: read
packages: write
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: true
include_release_path_suites: false
include_openwebui: true
include_live_suites: true
secrets:
@@ -74,4 +72,3 @@ jobs:
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 }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}

View File

@@ -13,7 +13,6 @@ on:
- "src/gateway/**"
- "src/media/**"
- ".github/workflows/parity-gate.yml"
workflow_dispatch:
permissions:
contents: read
@@ -24,7 +23,7 @@ concurrency:
jobs:
parity-gate:
name: Run the OpenAI / Opus 4.6 parity gate against the qa-lab mock
name: Run the GPT-5.4 / Opus 4.6 parity gate against the qa-lab mock
if: ${{ github.event.pull_request.draft != true }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
@@ -42,7 +41,6 @@ jobs:
# followthrough gate that expects a fast post-approval read within a 30s
# agent.wait timeout.
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
@@ -76,13 +74,13 @@ jobs:
# The approval-turn sentinel still runs inside the full parity pack below.
# Keep the exact mock read-plan contract in deterministic unit tests instead
# of paying for a separate full-runtime preflight that has been flaky in CI.
- name: Run OpenAI candidate lane
- name: Run GPT-5.4 lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4-alt \
--output-dir .artifacts/qa-e2e/gpt54
@@ -102,7 +100,7 @@ jobs:
--repo-root . \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--candidate-label openai/gpt-5.4 \
--baseline-label anthropic/claude-opus-4-6 \
--output-dir .artifacts/qa-e2e/parity

View File

@@ -1,445 +0,0 @@
name: QA-Lab - All Lanes
on:
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
inputs:
ref:
description: Ref, tag, or SHA to run
required: true
default: main
type: string
scenario:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
discord_scenario:
description: Optional comma-separated Discord scenario ids
required: false
type: string
permissions:
contents: read
pull-requests: read
concurrency:
group: qa-lab-all-lanes-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Require maintainer-level repository access
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "schedule") {
core.info("Scheduled default-branch QA run; actor permission check is only required for manual dispatch.");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
}
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
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: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
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"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_sha" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
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 this secret-bearing QA run." >&2
echo "Allowed refs must be on main, point to a release tag, match a release branch head, 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"
run_mock_parity:
name: Run QA Lab parity gate
needs: [validate_selected_ref]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
OPENCLAW_LIVE_OPENAI_KEY: ""
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
OPENCLAW_LIVE_GEMINI_KEY: ""
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Build private QA runtime
run: pnpm build
- name: Run OpenAI candidate lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model openai/gpt-5.4-alt \
--output-dir .artifacts/qa-e2e/gpt54
- name: Run Opus 4.6 lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model anthropic/claude-opus-4-6 \
--alt-model anthropic/claude-sonnet-4-6 \
--output-dir .artifacts/qa-e2e/opus46
- name: Generate parity report
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-6 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
run_live_matrix:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
run: pnpm build
- name: Run Matrix live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/matrix-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa matrix \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--fast
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENAI_API_KEY
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run Telegram live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/telegram-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
scenario_args=()
if [[ -n "${INPUT_SCENARIO// }" ]]; then
IFS=',' read -r -a raw_scenarios <<<"${INPUT_SCENARIO}"
for raw in "${raw_scenarios[@]}"; do
scenario="$(printf '%s' "${raw}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -n "${scenario}" ]]; then
scenario_args+=(--scenario "${scenario}")
fi
done
fi
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa telegram \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \
"${scenario_args[@]}"
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_discord:
name: Run Discord live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENAI_API_KEY
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run Discord live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/discord-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
scenario_args=()
if [[ -n "${INPUT_SCENARIO// }" ]]; then
IFS=',' read -r -a raw_scenarios <<<"${INPUT_SCENARIO}"
for raw in "${raw_scenarios[@]}"; do
scenario="$(printf '%s' "${raw}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -n "${scenario}" ]]; then
scenario_args+=(--scenario "${scenario}")
fi
done
fi
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa discord \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--credential-source convex \
--credential-role ci \
"${scenario_args[@]}"
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn

View File

@@ -29,7 +29,7 @@ jobs:
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Mark stale unassigned issues and pull requests (primary)
- name: Mark stale issues and pull requests (primary)
id: stale-primary
continue-on-error: true
uses: actions/stale@v10
@@ -56,60 +56,12 @@ jobs:
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.
- name: Mark stale assigned issues (primary)
id: assigned-issue-stale-primary
continue-on-error: true
uses: actions/stale@v10
with:
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
days-before-issue-stale: 30
days-before-issue-close: 10
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
operations-per-run: 2000
ascending: true
include-only-assigned: true
remove-stale-when-updated: true
stale-issue-message: |
This assigned issue has been automatically marked as stale after 30 days of inactivity.
Please add updates or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
close-issue-reason: not_planned
- name: Mark stale assigned pull requests (primary)
id: assigned-stale-primary
continue-on-error: true
uses: actions/stale@v10
with:
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 27
days-before-pr-close: 3
stale-pr-label: stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
include-only-assigned: true
ignore-pr-updates: true
remove-stale-when-updated: true
stale-pr-message: |
This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.
- name: Check stale state cache
id: stale-state
if: always()
@@ -134,7 +86,7 @@ jobs:
core.warning(`Failed to check stale state cache: ${message}`);
core.setOutput("has_state", "false");
}
- name: Mark stale unassigned issues and pull requests (fallback)
- name: Mark stale issues and pull requests (fallback)
if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
uses: actions/stale@v10
with:
@@ -160,58 +112,12 @@ jobs:
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.
- name: Mark stale assigned issues (fallback)
if: (steps.assigned-issue-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
uses: actions/stale@v10
with:
repo-token: ${{ steps.app-token-fallback.outputs.token }}
days-before-issue-stale: 30
days-before-issue-close: 10
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
operations-per-run: 2000
ascending: true
include-only-assigned: true
remove-stale-when-updated: true
stale-issue-message: |
This assigned issue has been automatically marked as stale after 30 days of inactivity.
Please add updates or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
close-issue-reason: not_planned
- name: Mark stale assigned pull requests (fallback)
if: (steps.assigned-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
uses: actions/stale@v10
with:
repo-token: ${{ steps.app-token-fallback.outputs.token }}
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 27
days-before-pr-close: 3
stale-pr-label: stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
include-only-assigned: true
ignore-pr-updates: true
remove-stale-when-updated: true
stale-pr-message: |
This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.
lock-closed-issues:
permissions:

View File

@@ -1,279 +0,0 @@
name: Test Performance Agent
on:
workflow_run: # zizmor: ignore[dangerous-triggers] main-only test optimization after trusted CI; job gates repository, event, branch, actor, conclusion, current main SHA, and daily cadence before using write token
workflows:
- CI
types:
- completed
workflow_dispatch:
permissions:
actions: read
contents: write
concurrency:
group: test-performance-agent-main
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
TEST_PERF_BEFORE: .artifacts/test-perf/baseline-before.json
TEST_PERF_AFTER: .artifacts/test-perf/after-agent.json
TEST_PERF_COMPARE: .artifacts/test-perf/agent-compare.json
jobs:
optimize-tests:
if: >
github.repository == 'openclaw/openclaw' &&
(github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
!endsWith(github.event.workflow_run.actor.login, '[bot]')))
runs-on: ubuntu-24.04
timeout-minutes: 240
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Gate trusted main activity and daily cadence
id: gate
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "workflow_run" ]; then
echo "run_agent=true" >> "$GITHUB_OUTPUT"
echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
exit 0
fi
for attempt in 1 2 3 4 5; do
if git fetch --no-tags origin main; then
break
fi
if [ "$attempt" = "5" ]; then
echo "Failed to fetch main after retries." >&2
exit 1
fi
echo "Fetch attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
remote_main="$(git rev-parse origin/main)"
if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then
echo "CI run is superseded by ${remote_main}; skipping test performance agent for ${WORKFLOW_HEAD_SHA}."
echo "run_agent=false" >> "$GITHUB_OUTPUT"
exit 0
fi
day_start="$(date -u +%Y-%m-%dT00:00:00Z)"
runs_json="$RUNNER_TEMP/test-performance-agent-runs.json"
gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/test-performance-agent.yml/runs" \
-f branch=main \
-f event=workflow_run \
-f per_page=50 > "$runs_json"
prior_runs="$(
jq -r \
--argjson current_run_id "$GITHUB_RUN_ID" \
--arg day_start "$day_start" \
'.workflow_runs[]
| select(.database_id != $current_run_id)
| select(.created_at >= $day_start)
| select(.status != "cancelled")
| select((.conclusion // "") != "skipped")
| [.database_id, .status, (.conclusion // ""), .created_at, .head_sha]
| @tsv' "$runs_json"
)"
if [ -n "$prior_runs" ]; then
echo "Test performance agent already ran or is running today; skipping."
printf '%s\n' "$prior_runs"
echo "run_agent=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "run_agent=true" >> "$GITHUB_OUTPUT"
echo "base_sha=${remote_main}" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
if: steps.gate.outputs.run_agent == 'true'
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Ensure test performance agent key exists
if: steps.gate.outputs.run_agent == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "Missing OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Build baseline full-suite performance report
if: steps.gate.outputs.run_agent == 'true'
run: pnpm test:perf:groups --full-suite --allow-failures --output "$TEST_PERF_BEFORE" --limit 20 --top-files 40
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: workspace-write
safety-strategy: drop-sudo
codex-args: '["--full-auto"]'
- name: Enforce focused test performance patch
if: steps.gate.outputs.run_agent == 'true'
id: patch
run: |
set -euo pipefail
untracked="$(git ls-files --others --exclude-standard)"
if [ -n "$untracked" ]; then
echo "Test performance agent created untracked files; forbidden:"
printf '%s\n' "$untracked"
exit 1
fi
added_deleted_or_renamed="$(git diff --name-status --diff-filter=ADR)"
if [ -n "$added_deleted_or_renamed" ]; then
echo "Test performance agent added, deleted, or renamed tracked files; forbidden:"
printf '%s\n' "$added_deleted_or_renamed"
exit 1
fi
bad_paths="$(
git diff --name-only | while IFS= read -r path; do
case "$path" in
apps/*|extensions/*|packages/*|scripts/*|src/*|Swabble/*|test/*|ui/*) ;;
*) printf '%s\n' "$path" ;;
esac
done
)"
if [ -n "$bad_paths" ]; then
echo "Test performance agent touched forbidden paths:"
printf '%s\n' "$bad_paths"
exit 1
fi
if git diff --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Restore Node 24 path
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
run:
| # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
set -euo pipefail
export PATH="${NODE_BIN}:${PATH}"
echo "${NODE_BIN}" >> "$GITHUB_PATH"
node -v
corepack enable
pnpm -v
- name: Run full-suite performance report after agent changes
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
run: pnpm test:perf:groups --full-suite --output "$TEST_PERF_AFTER" --limit 20 --top-files 40
- name: Compare test performance reports
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
run: pnpm test:perf:groups:compare "$TEST_PERF_BEFORE" "$TEST_PERF_AFTER" --output "$TEST_PERF_COMPARE" --limit 20 --top-files 40
- name: Enforce coverage-preserving test count
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
run: |
set -euo pipefail
node <<'NODE'
const fs = require("node:fs");
const before = JSON.parse(fs.readFileSync(process.env.TEST_PERF_BEFORE, "utf8"));
const after = JSON.parse(fs.readFileSync(process.env.TEST_PERF_AFTER, "utf8"));
if (before.failed) {
console.log("Baseline had failing configs; skipping total test-count comparison against partial report.");
process.exit(0);
}
const beforeTests = before.totals?.testCount ?? 0;
const afterTests = after.totals?.testCount ?? 0;
if (afterTests < beforeTests) {
console.error(`Test count decreased from ${beforeTests} to ${afterTests}; refusing coverage-reducing patch.`);
process.exit(1);
}
console.log(`Test count preserved: ${beforeTests} -> ${afterTests}.`);
NODE
- name: Check changed lanes
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
run: pnpm check:changed
- name: Commit test performance updates
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
TARGET_BRANCH: main
run: |
set -euo pipefail
if git diff --quiet; then
echo "No test performance changes."
exit 0
fi
git config user.name "openclaw-test-performance-agent[bot]"
git config user.email "openclaw-test-performance-agent[bot]@users.noreply.github.com"
git add apps extensions packages scripts src Swabble test ui
git commit --no-verify -m "test: optimize slow tests"
for attempt in 1 2 3 4 5; do
if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then
echo "Fetch attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
continue
fi
if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")"
if [ "$remote_main" != "$(git rev-parse HEAD^)" ]; then
echo "main advanced; rebasing test performance update onto ${remote_main}."
if ! git rebase "origin/${TARGET_BRANCH}"; then
echo "Test performance update no longer applies cleanly; skipping stale update."
git rebase --abort || true
exit 0
fi
pnpm check:changed
fi
echo "Test performance update attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push test performance updates after retries." >&2
exit 1
- name: Upload test performance artifacts
if: steps.gate.outputs.run_agent == 'true' && always()
uses: actions/upload-artifact@v7
with:
name: test-performance-agent-${{ github.run_id }}
path: .artifacts/test-perf/
if-no-files-found: ignore
retention-days: 14

40
.gitignore vendored
View File

@@ -97,36 +97,6 @@ USER.md
# local tooling
.serena/
# Local project-agent skill installs. Only repo-owned skills are visible by
# default; promoting a new repo skill should require an intentional `git add -f`.
.agents/skills/*
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/openclaw-ghsa-maintainer/
!.agents/skills/openclaw-ghsa-maintainer/**
!.agents/skills/openclaw-parallels-smoke/
!.agents/skills/openclaw-parallels-smoke/**
!.agents/skills/openclaw-pr-maintainer/
!.agents/skills/openclaw-pr-maintainer/**
!.agents/skills/openclaw-qa-testing/
!.agents/skills/openclaw-qa-testing/**
!.agents/skills/openclaw-release-maintainer/
!.agents/skills/openclaw-release-maintainer/**
!.agents/skills/openclaw-secret-scanning-maintainer/
!.agents/skills/openclaw-secret-scanning-maintainer/**
!.agents/skills/openclaw-test-heap-leaks/
!.agents/skills/openclaw-test-heap-leaks/**
!.agents/skills/openclaw-test-performance/
!.agents/skills/openclaw-test-performance/**
!.agents/skills/optimizetests/
!.agents/skills/optimizetests/**
!.agents/skills/parallels-discord-roundtrip/
!.agents/skills/parallels-discord-roundtrip/**
!.agents/skills/security-triage/
!.agents/skills/security-triage/**
!.agents/skills/tag-duplicate-prs-issues/
!.agents/skills/tag-duplicate-prs-issues/**
# Agent credentials and memory (NEVER COMMIT)
/memory/
.agent/*.json
@@ -158,14 +128,15 @@ dist/protocol.schema.json
# Synthing
**/.stfolder/
.dev-state
docs/superpowers
.superpowers/
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
.gitignore
test/config-form.analyze.telegram.test.ts
ui/src/ui/theme-variants.browser.test.ts
ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__
ui/.vitest-attachments
docs/superpowers
# Generated docs baseline artifacts (locally generated, only hashes tracked)
docs/.generated/*.json
@@ -176,13 +147,8 @@ changelog/fragments/
# Local scratch workspace
.tmp/
.vmux*
.artifacts/
test/fixtures/openclaw-vitest-unit-report.json
analysis/
.artifacts/qa-e2e/
extensions/qa-lab/web/dist/
# Generated bundled plugin runtime dependency manifests
extensions/**/.openclaw-runtime-deps.json
extensions/**/.openclaw-runtime-deps-stamp.json

View File

@@ -39,12 +39,7 @@
"details",
"summary",
"p",
"div",
"strong",
"span",
"iframe",
"h2",
"h3",
"picture",
"source",
"Tooltip",

View File

@@ -11,53 +11,24 @@
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",
"eslint/no-constructor-return": "error",
"eslint/no-div-regex": "error",
"eslint/no-extra-label": "error",
"eslint/no-empty-pattern": "error",
"eslint/no-lone-blocks": "error",
"eslint/no-multi-str": "error",
"eslint/no-new": "error",
"eslint/no-object-constructor": "error",
"eslint/no-proto": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-return-assign": "error",
"eslint/no-sequences": "error",
"eslint/no-self-compare": "error",
"eslint/no-shadow": "off",
"eslint/no-var": "error",
"eslint/no-useless-call": "error",
"eslint/no-useless-computed-key": "error",
"eslint/no-useless-concat": "error",
"eslint/no-useless-constructor": "error",
"eslint/no-warning-comments": "error",
"eslint/no-unmodified-loop-condition": "error",
"eslint/no-new-wrappers": "error",
"eslint/no-else-return": "error",
"eslint/no-case-declarations": "error",
"eslint/prefer-exponentiation-operator": "error",
"eslint/prefer-numeric-literals": "error",
"eslint/radix": "error",
"eslint/unicode-bom": "error",
"eslint/yoda": "error",
"import/no-absolute-path": "error",
"import/no-empty-named-blocks": "error",
"import/no-self-import": "error",
"node/no-exports-assign": "error",
"eslint-plugin-unicorn/prefer-set-size": "error",
"oxc/no-accumulating-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/no-map-spread": "error",
"promise/no-new-statics": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-tslint-comment": "error",
"typescript/consistent-return": "error",
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-arguments": "error",
"typescript/no-unnecessary-type-constraint": "error",
@@ -65,52 +36,15 @@
"typescript/no-unnecessary-type-parameters": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-useless-default-assignment": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "considerDefaultExhaustiveForUnions": true }
],
"typescript/prefer-return-this-type": "error",
"typescript/prefer-find": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-reduce-type-parameter": "error",
"typescript/prefer-ts-expect-error": "error",
"unicorn/consistent-date-clone": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-console-spaces": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-new-buffer": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-unnecessary-array-flat-depth": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
"unicorn/no-unnecessary-slice-end": "error",
"unicorn/no-useless-error-capture-stack-trace": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-dom-node-text-content": "error",
"unicorn/prefer-keyboard-event-key": "error",
"unicorn/prefer-array-some": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/prefer-number-properties": "error",
"unicorn/prefer-negative-index": "error",
"unicorn/prefer-optional-catch-binding": "error",
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-size": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/require-array-join-separator": "error",
"unicorn/require-number-to-fixed-digits-argument": "error",
"unicorn/require-post-message-target-origin": "error",
"unicorn/throw-new-error": "error",
"vitest/no-import-node-test": "error",
"vitest/consistent-vitest-vi": "error",
"vitest/prefer-called-once": "error",
"vitest/prefer-called-times": "error",
"vitest/prefer-expect-type-of": "error"
"unicorn/require-post-message-target-origin": "error"
},
"ignorePatterns": [
"assets/",

290
AGENTS.md
View File

@@ -1,181 +1,201 @@
# AGENTS.MD
Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
Telegraph style. Root rules only. Read scoped `AGENTS.md` before touching a subtree.
## Start
- Repo: `https://github.com/openclaw/openclaw`
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- Run docs list first: `pnpm docs:list` if available; read relevant docs only.
- High-confidence answers only when fixing/triaging: verify source, tests, shipped/current behavior, and dependency contracts before deciding.
- Dependency-backed behavior: read upstream dependency docs/source/types first. Do not assume APIs, defaults, errors, timing, or runtime behavior.
- Live-verify when feasible. Check env/`~/.profile` for keys before assuming live tests are blocked; keep secret output redacted.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
- Wording: product/docs/UI/changelog say "plugin/plugins"; `extensions/` is internal.
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink.
- Replies: repo-root file refs only, e.g. `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- CODEOWNERS: maintenance/refactors/tests are ok. For larger behavior, product, security, or ownership-sensitive changes, get a listed owner request/review first.
- First pass: run docs list (`pnpm docs:list`; ignore if unavailable), then read only relevant docs/guides.
- Missing deps: run `pnpm install`, rerun once, then report first actionable error.
- Use "plugin/plugins" in docs/UI/changelog. `extensions/` remains internal workspace layout.
- Add channel/plugin/app/doc surface: update `.github/labeler.yml` and matching GitHub labels.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink to it.
## Map
## Repo Map
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`, `Swabble/`.
- Installers: sibling `../openclaw.ai`.
- Scoped guides exist in: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
- Core TS: `src/`, `ui/`, `packages/`
- Bundled plugins: `extensions/`
- Plugin SDK/public contract: `src/plugin-sdk/*`
- Core channel internals: `src/channels/*`
- Plugin loader/registry/contracts: `src/plugins/*`
- Gateway protocol: `src/gateway/protocol/*`
- Docs: `docs/`
- Apps: `apps/`, `Swabble/`
- Installers served from `openclaw.ai`: sibling `../openclaw.ai`
Scoped guides:
- `extensions/AGENTS.md`: bundled plugin rules
- `src/plugin-sdk/AGENTS.md`: public SDK rules
- `src/channels/AGENTS.md`: channel core rules
- `src/plugins/AGENTS.md`: plugin loader/registry rules
- `src/gateway/AGENTS.md`, `src/gateway/protocol/AGENTS.md`: gateway/protocol rules
- `src/agents/AGENTS.md`: agent import/test perf rules
- `test/helpers/AGENTS.md`, `test/helpers/channels/AGENTS.md`: shared test helpers
- `docs/AGENTS.md`, `ui/AGENTS.md`, `scripts/AGENTS.md`: docs/UI/scripts
## Architecture
- Core stays extension-agnostic. No bundled ids in core when manifest/registry/capability contracts work.
- Extensions cross into core only via `openclaw/plugin-sdk/*`, manifest metadata, injected runtime helpers, documented barrels (`api.ts`, `runtime-api.ts`).
- Extension prod code: no core `src/**`, `src/plugin-sdk-internal/**`, other extension `src/**`, or relative outside package.
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use `api.ts`, SDK facade, generic contracts.
- Extension-owned behavior stays extension-owned: repair, detection, onboarding, auth/provider defaults, provider tools/settings.
- Legacy config repair: doctor/fix paths, not startup/load-time core migrations.
- Core test asserting extension-specific behavior: move to owner extension or generic contract test.
- Core must stay extension-agnostic. No core special cases for bundled plugin/provider/channel ids when manifest/registry/capability contracts can express it.
- Extensions cross into core only via `openclaw/plugin-sdk/*`, manifest metadata, injected runtime helpers, and documented local barrels (`api.ts`, `runtime-api.ts`).
- Extension production code must not import core `src/**`, `src/plugin-sdk-internal/**`, another extension's `src/**`, or relative paths outside its package.
- Core code/tests must not deep-import plugin internals (`extensions/*/src/**`, `onboard.js`). Use plugin `api.ts` / public SDK facade / generic contract.
- Extension-owned behavior stays in the extension: legacy repair, detection, onboarding, auth/provider defaults, provider tools/settings.
- Legacy config repair: prefer doctor/fix paths over startup/load-time core migrations.
- If a core test asserts extension-specific behavior, move it to the owning extension or a generic contract test.
- New seams: backwards-compatible, documented, versioned. Third-party plugins exist.
- Channels: `src/channels/**` is implementation; plugin authors get SDK seams.
- Providers: core owns generic loop; provider plugins own auth/catalog/runtime hooks.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor.
- Direction: manifest-first control plane; targeted runtime loaders; no hidden contract bypasses; broad mutable registries transitional.
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
- Channels: `src/channels/**` is implementation. Plugin authors get SDK seams, not channel internals.
- Providers: core owns generic inference loop; provider plugins own provider-specific auth/catalog/runtime hooks.
- Gateway protocol changes are contract changes: additive first; incompatible needs versioning/docs/client follow-through.
- Config contract: keep exported types, schema/help, generated metadata, baselines, docs aligned. Retired public keys stay retired; compatibility belongs in raw migration/doctor paths.
- Plugin architecture direction: manifest-first control plane; targeted runtime loaders; no hidden paths around declared contracts; broad mutable registries are transitional.
- Prompt-cache rule: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
## Commands
- Runtime: Node 22+. Keep Node + Bun paths working.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
- Smart gate: `pnpm check:changed`; explain `pnpm changed:lanes --json`; staged preview `pnpm check:changed --staged`.
- Sparse worktrees: `pnpm check:changed` is sparse-safe and may skip sparse-missing typecheck projects; do not expand sparse checkout just to satisfy changed-gate tsgo. Direct `pnpm tsgo*` remains strict; use a fuller worktree when you need direct typecheck proof.
- Prod sweep: `pnpm check`; tests: `pnpm test`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Targeted tests: `pnpm test <path-or-filter> [vitest args...]`; never raw `vitest`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); do not add `tsc --noEmit`, `typecheck`, `check:types`.
- Format/lint: `pnpm format:check`/`pnpm format`; `pnpm lint*` lanes.
- Heavy checks: `OPENCLAW_LOCAL_CHECK=1`, mode `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`; CI/shared use `OPENCLAW_LOCAL_CHECK=0`.
- Local first. Use repo `pnpm` lanes before Blacksmith/Testbox. Remote only for parity-only failures, secrets/services, or explicit ask.
## GitHub / CI
- Triage: list first, hydrate few. Use bounded `gh --json --jq`; avoid repeated full comment scans.
- Automatic PR/issue discovery: skip maintainer-owned items unless directly relevant. Do not comment, close, label, retitle, rebase, fix up, or land them without Peter asking.
- Search/dedupe: prefer `gh search issues 'repo:openclaw/openclaw is:open <terms>' --json number,title,state,updatedAt --limit 20`.
- GitHub search boolean text is fussy. If `OR` queries return empty, split exact terms and search title/body/comments separately before concluding no hits.
- PR shortlist: `gh pr list ...`; then `gh pr view <n> --json number,title,body,closingIssuesReferences,files,statusCheckRollup,reviewDecision`.
- After landing PR: search duplicate open issues/PRs. Before closing: comment why + canonical link.
- GH comments with markdown backticks, `$`, or shell snippets: avoid inline double-quoted `--body`; use single quotes or `--body-file`.
- PR execution artifacts/screenshots: attach them to the PR, comment, or an external artifact store. Do not add `.github/pr-assets` or other PR-only assets to the repo.
- PR review answer must explicitly cover: what bug/behavior we are trying to fix; PR/issue URL(s) and affected endpoint/surface; whether this is the best possible fix, with high-certainty evidence from code, tests, CI, and shipped/current behavior.
- CI polling: exact SHA, needed fields only. Example: `gh api repos/<owner>/<repo>/actions/runs/<id> --jq '{status,conclusion,head_sha,updated_at,name,path}'`.
- Post-land wait: minimal. Exact landed SHA only. If superseded on `main`, same-branch `cancel-in-progress` cancellations are expected; stop once local touched-surface proof exists. Never wait for newer unrelated `main` unless asked.
- Wait matrix:
- never: `Auto response`, `Labeler`, `Docs Sync Publish Repo`, `Docs Agent`, `Test Performance Agent`, `Stale`.
- conditional: `CI` exact SHA only; `Docs` only docs task/no local docs proof; `Workflow Sanity` only workflow/composite/CI-policy edits; `Plugin NPM Release` only plugin package/release metadata.
- release/manual only: `Docker Release`, `OpenClaw NPM Release`, `macOS Release`, `OpenClaw Release Checks`, `Cross-OS Release Checks`, `NPM Telegram Beta E2E`.
- explicit/surface only: `QA-Lab - All Lanes`, `Scheduled Live And E2E`, `Install Smoke`, `CodeQL`, `Sandbox Common Smoke`, `Parity gate`, `Blacksmith Testbox`, `Control UI Locale Refresh`.
- `/landpr`: do not idle on `auto-response` or `check-docs`. Treat docs as local proof unless `check-docs` already failed with actionable relevant error.
- Poll 30-60s. Fetch jobs/logs/artifacts only after failure/completion or concrete need.
- Runtime: Node 22+. Keep Node and Bun paths working.
- Install: `pnpm install` (Bun supported; keep lockfiles/patches aligned if touched).
- Dev CLI: `pnpm openclaw ...` or `pnpm dev`.
- Build: `pnpm build`
- Smart local gate: `pnpm check:changed` (scoped typecheck/lint/guards + relevant tests)
- Explain smart gate: `pnpm changed:lanes --json`
- Pre-commit view: `pnpm check:changed --staged`
- Normal full prod sweep: `pnpm check` (prod typecheck/lint/guards, no tests)
- Full tests: `pnpm test`
- Changed tests only: `pnpm test:changed`
- Local serial loop: `pnpm test:serial`
- Extension tests: `pnpm test:extensions` or `pnpm test extensions` = all extension shards; `pnpm test extensions/<id>` = one extension lane. Heavy channels/OpenAI have dedicated shards.
- Shard timing artifact: `.artifacts/vitest-shard-timings.json`; auto-used for balanced shard ordering. Disable with `OPENCLAW_TEST_PROJECTS_TIMINGS=0`.
- Targeted tests: `pnpm test <path-or-filter> [vitest args...]`; do not call raw `vitest`.
- Coverage: `pnpm test:coverage`
- Format check/fix: `pnpm format:check` / `pnpm format`
- Typecheck:
- `pnpm tsgo`: fastest core prod graph
- `pnpm tsgo:prod`: core + extensions prod graphs; used by `pnpm check`
- `pnpm check:test-types` / `pnpm tsgo:test`: all test graphs
- `pnpm tsgo:all`: all prod + test project refs
- Debug slices exist; do not present as normal user flow.
- Profile: `pnpm tsgo:profile [core-test|extensions-test|--all]`
- Type policy: use `tsgo`; do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes. `tsc` only for declaration/package-boundary emit gaps.
- Lint:
- `pnpm lint`: core/extensions/scripts shards
- `pnpm lint:core`, `pnpm lint:extensions`, `pnpm lint:scripts`
- `pnpm lint:apps`: Swift/app surface, separate from TS lint
- `pnpm lint:all`: legacy comparison lane
- Local heavy-check behavior: `OPENCLAW_LOCAL_CHECK=1` default; `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`; `OPENCLAW_LOCAL_CHECK=0` for CI/shared runs.
- Local validation is local-first. Do not default to Blacksmith/Testbox for routine OpenClaw iteration; it burns warm caches and startup time. Use repo `pnpm` lanes first, then reach for remote CI/Testbox only for parity-only failures, secrets/services, or when explicitly requested.
## Gates
- Pre-commit hook: staged formatting only. Validation explicit.
- Pre-commit hook: staged format/lint, then `pnpm check:changed --staged`; docs/markdown-only skips changed-scope check; `FAST_COMMIT=1` skips changed-scope check only.
- Changed lanes:
- core prod: core prod typecheck + core tests
- core tests: core test typecheck/tests
- extension prod: extension prod typecheck + extension tests
- extension tests: extension test typecheck/tests
- public SDK/plugin contract: extension prod/test too
- unknown root/config: all lanes
- Before handoff/push for code/test/runtime/config changes: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`.
- Docs/changelog-only and CI/workflow metadata-only changes are not changed-gate work by default. Use `git diff --check` plus the relevant formatter/docs/workflow sanity check; escalate to `pnpm check:changed` only when scripts, test config, generated docs/API, package metadata, or runtime/build behavior changed.
- Rebase sanity: after a green `pnpm check:changed`, a clean rebase onto current
`origin/main` does not require rerunning the full changed gate when the rebase
has no conflicts and the branch diff is materially unchanged. Do a quick
`git status`, `git diff --check`, and diff/stat sanity check; rerun targeted or
full checks only if conflict resolution, upstream overlap, generated drift,
dependency/config changes, or touched-file content changes make the prior
result stale.
- Landing on `main`: verify touched surface near landing. Default feasible bar: `pnpm check` + `pnpm test`.
- Hard build gate: `pnpm build` before push if build output, packaging, lazy/module boundaries, or published surfaces can change.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Generated/API drift: `pnpm check:architecture`, `pnpm config:docs:gen/check`, `pnpm plugin-sdk:api:gen/check`. Track `docs/.generated/*.sha256`; full JSON ignored.
- core prod => core prod typecheck + core tests
- core tests => core test typecheck/tests only
- extension prod => extension prod typecheck + extension tests
- extension tests => extension test typecheck/tests only
- public SDK/plugin contract => extension prod/test validation too
- unknown root/config => all lanes
- Local loop: prefer `pnpm check:changed`; use `pnpm test:changed` for tests only; use `pnpm check` for full prod TS/lint sweep without tests.
- Landing on `main`: verify touched surface near landing; default bar is `pnpm check` + `pnpm test` when feasible.
- Hard build gate: run/pass `pnpm build` before push if build output, packaging, lazy/module boundaries, or published surfaces can change.
- Do not land related failing format/lint/type/build/tests. If failures are unrelated on latest `origin/main`, say so and give scoped proof.
- CI architecture gate: `check-additional`; local equivalent `pnpm check:architecture`.
- Config docs drift: `pnpm config:docs:gen/check`
- Plugin SDK API drift: `pnpm plugin-sdk:api:gen/check`
- Generated docs baselines: tracked `docs/.generated/*.sha256`; full JSON ignored.
## Code
## Code Style
- TS ESM, strict. Avoid `any`; prefer real types, `unknown`, narrow adapters.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- TypeScript ESM. Strict types. Avoid `any`; prefer real types/`unknown`/narrow adapters.
- No `@ts-nocheck`. No lint suppressions unless intentional and explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings.
- Avoid semantic sentinels: `?? 0`, empty object/string, etc.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
- Comments: brief, only non-obvious logic.
- Split files around ~700 LOC when clarity/testability improves.
- Naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
- English: American spelling.
- Runtime branching: prefer discriminated unions / closed codes over freeform strings.
- Avoid magic sentinels like `?? 0`, empty object/string when semantics change.
- Dynamic import: do not mix static and dynamic import for same module in prod path. Use dedicated `*.runtime.ts` lazy boundary. After lazy-boundary edits, run `pnpm build` and check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` and architecture/madge cycle checks green.
- Classes: no prototype mixins/mutations. Use explicit inheritance/composition. Tests prefer per-instance stubs.
- Comments: brief only for non-obvious logic.
- File size: split around ~700 LOC when it improves clarity/testability.
- Product naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
- Written English: American spelling.
## Tests
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.4`.
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
- Hot tests: avoid per-test `vi.resetModules()` + heavy imports. Measure with `pnpm test:perf:imports <file>` / `pnpm test:perf:hotspots --limit N`.
- Seam depth: pure helper/contract unit tests; one integration smoke per boundary.
- Mock expensive seams directly: scanners, manifests, registries, fs crawls, provider SDKs, network/process launch.
- Prefer injection; if module mocking, mock narrow local `*.runtime.ts`, not broad barrels or `openclaw/plugin-sdk/*`.
- Share fixtures/builders; delete duplicate assertions; assert behavior that can regress here.
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
- Test workers max 16. Memory pressure: `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
- Live: `OPENCLAW_LIVE_TEST=1 pnpm test:live`; verbose `OPENCLAW_LIVE_TEST_QUIET=0`.
- Guide: `docs/help/testing.md`.
- Vitest. Tests colocated `*.test.ts`; e2e `*.e2e.test.ts`.
- Example models in tests: `sonnet-4.6`, `gpt-5.4`.
- Clean up timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` must stay safe.
- Hot tests: avoid per-test `vi.resetModules()` + fresh heavy imports; prefer static or `beforeAll` imports and reset state directly.
- Measure first: `pnpm test:perf:imports <file>` for import drag; `pnpm test:perf:hotspots --limit N` for suite targets.
- Keep tests at seam depth: unit-test pure helpers/contracts; one integration smoke per boundary, not per branch.
- Mock expensive runtime seams directly: scanners, manifests, package registries, filesystem crawls, provider SDKs, network/process launch.
- Prefer injected deps over module mocks; if mocking modules, mock narrow local `*.runtime.ts` seams, not broad barrels.
- Share fixtures/builders; do not recreate temp dirs, package manifests, or plugin workspaces in every case unless state isolation needs it.
- Delete duplicate assertions when another test owns the boundary; assert only the behavior that can regress here.
- Avoid broad `importOriginal()` / broad `openclaw/plugin-sdk/*` partial mocks in hot tests. Add narrow local `*.runtime.ts` seam and mock it.
- Use existing deps/callback/runtime injection seams before module mocks.
- Import-dominated test time is a boundary smell; shrink import surface before adding cases.
- Replacing slow integration coverage: extract production composition into a named helper and test that helper.
- Do not modify baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
- Do not set test workers above 16. For memory pressure: `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
- Live: `OPENCLAW_LIVE_TEST=1 pnpm test:live`; full logs `OPENCLAW_LIVE_TEST_QUIET=0`.
- Full testing guide: `docs/help/testing.md`.
## Docs / Changelog
- Docs change with behavior/API. Use docs list/read_when hints; docs links per `docs/AGENTS.md`.
- Changelog user-facing only; pure test/internal usually no entry.
- Changelog placement: active version `### Changes`/`### Fixes`; every added entry must include at least one `Thanks @author` attribution, using credited GitHub username(s). Never add `Thanks @steipete`.
- Changelog bullets are always single-line. No wrapping/continuation across multiple lines. Long entries stay on one long line so dedupe, PR-ref, and credit-audit tooling work and so the visual style stays uniform.
- Update docs when behavior/API changes. Use docs list/read_when hints.
- Docs links: see `docs/AGENTS.md`.
- Changelog: user-facing only. Pure test/internal changes usually no entry.
- Changelog placement: append to active version `### Changes`/`### Fixes`; at most one contributor mention, prefer `Thanks @user`.
## Git
- Commit via `scripts/committer "<msg>" <file...>`; stage intended files only. It formats staged files; still run gates.
- Commits: conventional-ish, concise, grouped.
- No manual stash/autostash unless explicit. No branch/worktree changes unless requested.
- `main`: no merge commits; rebase on latest `origin/main` before push. Do not
keep chasing `main` with repeated full gates after one green run plus a clean
rebase sanity pass.
- User says `commit`: your changes only. `commit all`: all changes in grouped chunks. `push`: may `git pull --rebase` first.
- Do not delete/rename unexpected files; ask if blocking, else ignore.
- Bulk PR close/reopen >5: ask with count/scope.
- PR/issue workflows: `$openclaw-pr-maintainer`. `/landpr`: `~/.codex/prompts/landpr.md`.
- Use `scripts/committer "<msg>" <file...>`; stage only intended files.
- Commits: conventional-ish, concise/action-oriented. Group related changes.
- No manual stash/autostash unless explicitly requested. No branch/worktree changes unless requested.
- No merge commits on `main`; rebase on latest `origin/main` before push.
- User says "commit": commit your changes only. "commit all": commit everything in grouped chunks. "push": may `git pull --rebase` first.
- Do not delete/rename unexpected files; ask if it blocks. Otherwise ignore unrelated WIP.
- If bulk PR close/reopen affects >5 PRs, ask with exact count/scope.
- PR/issue workflows: use `$openclaw-pr-maintainer`.
- `/landpr`: use `~/.codex/prompts/landpr.md`.
## Security / Release
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Secrets: channel/provider credentials under `~/.openclaw/credentials/`; model auth profiles under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Env keys: check `~/.profile`.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm.patchedDependencies` exact versions only.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Release docs: `docs/reference/RELEASING.md`; use `$openclaw-release-maintainer`.
- GHSA/advisories: `$openclaw-ghsa-maintainer`.
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
- Dependency patches/overrides/vendor changes require explicit approval. `pnpm.patchedDependencies` must use exact versions.
- Carbon pins owner-only: do not change `@buape/carbon` versions unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps require explicit approval.
- Release docs: `docs/reference/RELEASING.md`; use `$openclaw-release-maintainer`.
- GHSA/advisories: use `$openclaw-ghsa-maintainer`.
- Beta tag/version must match, e.g. `vYYYY.M.D-beta.N` => npm `YYYY.M.D-beta.N --tag beta`.
## Apps / Platform
- Before simulator/emulator testing, check real iOS/Android devices.
- Before simulator/emulator testing, check connected real iOS/Android devices first.
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
- Mac gateway: use app or `openclaw gateway restart/status --deep`; no ad-hoc tmux gateway. Logs: `./scripts/clawlog.sh`.
- Version bump touches: `package.json`, `apps/android/app/build.gradle.kts`, `apps/ios/version.json` + `pnpm ios:version:sync`, macOS `Info.plist`, `docs/install/updating.md`. Appcast only for Sparkle release.
- Mobile LAN pairing: plaintext `ws://` loopback-only. Private-network `ws://` needs `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; Tailscale/public use `wss://` or tunnel.
- SwiftUI: prefer Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
- mac gateway: use app or `openclaw gateway restart/status --deep`; avoid ad-hoc tmux gateway sessions. Rebuild mac app locally, not over SSH.
- mac logs: `./scripts/clawlog.sh`.
- Version bump touches: `package.json`, `apps/android/app/build.gradle.kts`, `apps/ios/version.json` then `pnpm ios:version:sync`, `apps/macos/.../Info.plist`, `docs/install/updating.md`. Appcast only for Sparkle release.
- iOS Team ID: `security find-identity -p codesigning -v`; fallback `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- Mobile LAN pairing: plaintext `ws://` is loopback-only by default. Trusted private-network `ws://` needs `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; Tailscale/public use `wss://` or a tunnel.
- A2UI hash `src/canvas-host/a2ui/.bundle.hash`: generated; ignore unless running `pnpm canvas:a2ui:bundle`; commit separately.
## Ops / Footguns
## External Ops
- Remote install docs: `docs/install/exe-dev.md`, `docs/install/fly.md`, `docs/install/hetzner.md`.
- Parallels smoke: `$openclaw-parallels-smoke`; Discord roundtrip: `parallels-discord-roundtrip`.
## Misc Footguns
- Remote install docs: `docs/install/{exe-dev,fly,hetzner}.md`. Parallels smoke: `$openclaw-parallels-smoke`; Discord roundtrip: `parallels-discord-roundtrip`.
- Rebrand/migration/config warnings: run `openclaw doctor`.
- Never edit `node_modules`.
- Local-only `.agents` ignores: `.git/info/exclude`, not repo `.gitignore`.
- CLI progress: `src/cli/progress.ts`; status tables: `src/terminal/table.ts`.
- Local-only `.agents` ignores: use `.git/info/exclude`, not repo `.gitignore`.
- CLI progress: use `src/cli/progress.ts`; status tables: `src/terminal/table.ts`.
- Connection/provider additions: update all UI surfaces + docs + status/config forms.
- Provider tool schemas: prefer flat string enum helpers over `Type.Union([Type.Literal(...)])`; some providers reject `anyOf`. Not a repo-wide protocol/schema ban.
- External messaging: no token-delta channel messages. Follow `docs/concepts/streaming.md`; preview/block streaming uses edits/chunks and preserves final/fallback delivery.
- Provider-facing tool schemas: prefer flat string enum helpers over `Type.Union([Type.Literal(...)])`; some providers reject generated `anyOf`. Do not treat this as a repo-wide protocol/schema ban.
- External messaging surfaces: no token-delta channel messages. Follow `docs/concepts/streaming.md`; preview/block streaming uses message edits/chunks and must preserve final/fallback delivery.

File diff suppressed because it is too large Load Diff

View File

@@ -29,9 +29,9 @@ ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f3411
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
COPY ${OPENCLAW_BUNDLED_PLUGIN_DIR} /tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Copy package.json for opted-in extensions so pnpm resolves their deps.
RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \
mkdir -p /out && \
RUN mkdir -p /out && \
for ext in $OPENCLAW_EXTENSIONS; do \
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
mkdir -p "/out/$ext" && \
@@ -173,7 +173,6 @@ RUN chown node:node /app
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
COPY --from=runtime-assets --chown=node:node /app/package.json .
COPY --from=runtime-assets --chown=node:node /app/patches ./patches
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
COPY --from=runtime-assets --chown=node:node /app/skills ./skills

View File

@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.14+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.14+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
@@ -119,7 +119,7 @@ openclaw onboard --install-daemon
openclaw gateway --port 18789 --verbose
# Send a message
openclaw message send --target +1234567890 --message "Hello from OpenClaw"
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/QQ/WebChat)
openclaw agent --message "Ship checklist" --thinking high

View File

@@ -288,7 +288,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.14.0 or later** (LTS). This version includes important security patches:
OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -296,7 +296,7 @@ OpenClaw requires **Node.js 22.14.0 or later** (LTS). This version includes impo
Verify your Node.js version:
```bash
node --version # Should be v22.14.0 or later
node --version # Should be v22.12.0 or later
```
### Docker Security

View File

@@ -35,18 +35,11 @@ public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public let trigger: String?
public init(
triggerEndTime: TimeInterval,
postGap: TimeInterval,
command: String,
trigger: String? = nil)
{
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
self.trigger = trigger
}
}
@@ -60,17 +53,13 @@ public enum WakeWordGate {
}
private struct TriggerTokens {
let source: String
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let endIndex: Int
let tokenCount: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
let trigger: String
}
public static func match(
@@ -98,19 +87,9 @@ public enum WakeWordGate {
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
let endIndex = i + count - 1
if let best {
if endIndex < best.endIndex { continue }
if endIndex == best.endIndex, count <= best.tokenCount { continue }
}
if let best, i <= best.index { continue }
best = MatchCandidate(
index: i,
endIndex: endIndex,
tokenCount: count,
triggerEnd: triggerEnd,
gap: gap,
trigger: trigger.source)
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}
@@ -118,11 +97,7 @@ public enum WakeWordGate {
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(
triggerEndTime: best.triggerEnd,
postGap: best.gap,
command: command,
trigger: best.trigger)
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
}
public static func commandText(
@@ -170,7 +145,7 @@ public enum WakeWordGate {
.map { normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
output.append(TriggerTokens(tokens: tokens))
}
return output
}

View File

@@ -47,21 +47,6 @@ import Testing
#expect(match?.command == "do it")
}
@Test func matchPrefersMostSpecificTriggerWhenOverlapping() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd", "hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.trigger == "hey clawd")
}
@Test func commandTextHandlesForeignRangeIndices() {
let transcript = "hey clawd do thing"
let other = "do thing"

View File

@@ -53,24 +53,12 @@ We prioritize secure defaults, but also expose clear knobs for trusted high-powe
OpenClaw has an extensive plugin API.
Core stays lean; optional capability should usually ship as plugins.
We are generally slimming down core while expanding what plugins can do.
If a useful feature cannot be built as a plugin yet, we welcome PRs and design discussions that extend the plugin API instead of adding one-off core behavior.
There are two broad plugin styles:
- Code plugins run OpenClaw plugin code and are appropriate for deeper runtime extension.
- Bundle-style plugins package stable external surfaces such as skills, MCP servers, and related configuration.
Prefer bundle-style plugins when they can express the capability.
They have a smaller, more stable interface and better security boundaries.
Use code plugins when the capability needs runtime hooks, providers, channels, tools, or other in-process extension points.
Preferred plugin path is npm package distribution plus local extension loading for development.
If you build a plugin, host and maintain it in your own repository.
The bar for adding optional plugins to core is intentionally high.
Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md)
Plugin discovery, official publisher status, provenance, and security review live in [ClawHub](https://clawhub.ai/).
OpenClaw docs should document core extension points; plugin promotion belongs in ClawHub, preferably under vetted org publishers for official plugins.
Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community
Memory is a special plugin slot where only one memory plugin can be active at a time.
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
@@ -78,16 +66,21 @@ Today we ship multiple memory options; over time we plan to converge on one reco
### Skills
We still ship some bundled skills for baseline UX.
New skills should be published through [ClawHub](https://clawhub.ai/) first, not added to core by default.
Official or bundled promotion should require a clear product, security, or maintainer-ownership reason.
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
Core skill additions should be rare and require a strong product or security reason.
### MCP Support
OpenClaw supports MCP as both a server and a runtime integration surface.
MCP details live in [`docs/cli/mcp.md`](docs/cli/mcp.md).
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
The project goal is pragmatic MCP support without duplicating existing agent,
tool, ACPX, plugin, or ClawHub paths.
This keeps MCP integration flexible and decoupled from core runtime:
- add or change MCP servers without restarting the gateway
- keep core tool/context surface lean
- reduce MCP churn impact on core stability and security
For now, we prefer this bridge model over building first-class MCP runtime into core.
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
### Setup
@@ -105,11 +98,11 @@ It is widely known, fast to iterate in, and easy to read, modify, and extend.
## What We Will Not Merge (For Now)
- New core skills when they can live on [ClawHub](https://clawhub.ai/)
- New core skills when they can live on ClawHub
- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later)
- Commercial service integrations that do not clearly fit the model-provider category
- Wrapper channels around already supported channels without a clear capability or security gap
- MCP work that duplicates existing MCP, ACPX, plugin, or ClawHub paths without a clear product or security gap
- First-class MCP runtime in core when `mcporter` already provides the integration path
- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture
- Heavy orchestration layers that duplicate existing agent and tool infrastructure

View File

@@ -2,255 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.4.24</title>
<pubDate>Sat, 25 Apr 2026 19:34:45 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042490</sparkle:version>
<sparkle:shortVersionString>2026.4.24</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.24</h2>
<h3>Highlights</h3>
<ul>
<li>Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.</li>
<li>DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.</li>
<li>Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.</li>
<li>Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.</li>
<li>Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Packaged installs: preserve package-root runtime dependencies and their exported subpaths when bundled plugin runtime mirrors fall back to copying shared chunks, fixing Windows npm updates that could fail to load copied <code>dist</code> modules.</li>
<li>Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing <code>every</code> values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys.</li>
<li>Telegram: remove the startup persisted-offset <code>getUpdates</code> preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar.</li>
<li>Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai.</li>
<li>Browser/aria snapshots: bind <code>format=aria</code> <code>axN</code> refs to live DOM nodes through backend DOM ids when Playwright is available, so follow-up browser actions can use those refs without timing out. (#62434) Thanks @MrKipler.</li>
<li>Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer <code>getUpdates</code> conflict diagnostics for external duplicate pollers. Fixes #56230.</li>
<li>Browser/Linux: detect Chromium-based installs under <code>/opt/google</code>, <code>/opt/brave.com</code>, <code>/usr/lib/chromium</code>, and <code>/usr/lib/chromium-browser</code> before asking users to set <code>browser.executablePath</code>. (#48563) Thanks @lupuletic.</li>
<li>Sessions/browser: close tracked browser tabs when idle, daily, <code>/new</code>, or <code>/reset</code> session rollover archives the previous transcript, preventing tabs from leaking past the old session. Thanks @jakozloski.</li>
<li>Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. Thanks @jalehman.</li>
<li>OpenAI/Codex: send Codex Responses system prompts through top-level</li>
</ul>
<code>instructions</code> while preserving the existing native Codex payload controls.
<ul>
<li>MCP/CLI: retire bundled MCP runtimes at the end of one-shot <code>openclaw agent</code> and <code>openclaw infer model run</code> gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457.</li>
<li>OpenAI/Codex image generation: canonicalize legacy <code>openai-codex.baseUrl</code> values such as <code>https://chatgpt.com/backend-api</code> to the Codex Responses backend before calling <code>gpt-image-2</code>, matching the chat transport. Fixes #71460.</li>
<li>Control UI: make <code>/usage</code> use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042.</li>
<li>GitHub Copilot: preserve encrypted Responses reasoning item IDs during replay so Copilot can validate encrypted reasoning payloads across requests. (#71448) Thanks @a410979729-sys.</li>
<li>Agents/replies: recover final-answer text when streamed assistant chunks contain only whitespace, preventing completed turns from surfacing as empty-payload errors. Fixes #71454. (#71467) Thanks @Sanjays2402.</li>
<li>Feishu/TTS: transcode voice-intent MP3 and other audio replies to Ogg/Opus before sending native Feishu audio bubbles, while keeping ordinary MP3 attachments as files. Fixes #61249 and #37868.</li>
<li>Telegram/webhook: acknowledge validated webhook updates before running bot middleware, keeping slow agent turns from tripping Telegram delivery retries while preserving per-chat processing lanes. Fixes #71392. Thanks @joelforsberg46-source.</li>
<li>MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add <code>mcp.sessionIdleTtlMs</code> idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808.</li>
<li>MCP/config reload: hot-apply <code>mcp.*</code> changes by disposing cached session MCP runtimes, and dispose bundled MCP runtimes during gateway shutdown so removed <code>mcp.servers</code> entries reap child processes promptly. Fixes #60656.</li>
<li>Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev.</li>
<li>Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed <code>{ type: "text" }</code> blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek, @alvinttang, and @coffeexcoin.</li>
<li>Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring <code>NIX_PROFILES</code> right-to-left precedence and falling back to <code>~/.nix-profile/bin</code> when unset. Fixes #44402. (#59935) Thanks @jerome-benoit.</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.24/OpenClaw-2026.4.24.zip" length="48033180" type="application/octet-stream" sparkle:edSignature="wxOfxadSZ/9iXMitaC6SA9J6YPZC3P2tkeK7HZPHzjUIlzQTvOl7EjR4aRyXzaYt1N1AK5ba+YhuCwEngrTdCQ=="/>
</item>
<item>
<title>2026.4.22</title>
<pubDate>Thu, 23 Apr 2026 15:18:00 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042290</sparkle:version>
<sparkle:shortVersionString>2026.4.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.22</h2>
<h3>Changes</h3>
<ul>
<li>Providers/xAI: add image generation, text-to-speech, and speech-to-text support, including <code>grok-imagine-image</code> / <code>grok-imagine-image-pro</code>, reference-image edits, six live xAI voices, MP3/WAV/PCM/G.711 TTS formats, <code>grok-stt</code> audio transcription, and xAI realtime transcription for Voice Call streaming. (#68694) Thanks @KateWilkins.</li>
<li>Providers/STT: add Voice Call streaming transcription for Deepgram, ElevenLabs, and Mistral, alongside the existing OpenAI and xAI realtime STT paths; ElevenLabs also gains Scribe v2 batch audio transcription for inbound media.</li>
<li>TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.</li>
<li>Onboarding: auto-install missing provider and channel plugins during setup so first-run configuration can complete without manual plugin recovery.</li>
<li>OpenAI/Responses: use OpenAI's native <code>web_search</code> tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed <code>web_search</code> tool.</li>
<li>Models/commands: add <code>/models add <provider> <modelId></code> so you can register a model from chat and use it without restarting the gateway; keep <code>/models</code> as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman.</li>
<li>WhatsApp: add configurable native reply quoting with replyToMode for WhatsApp conversations. Thanks @mcaxtr.</li>
<li>WhatsApp/groups+direct: forward per-group and per-direct <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> so configured per-chat behavioral instructions are injected on every turn. Supports <code>"*"</code> wildcard fallback and account-scoped overrides under <code>channels.whatsapp.accounts.<id>.{groups,direct}</code>; account maps fully replace root maps (no deep merge), matching the existing <code>requireMention</code> pattern. Closes #7011. (#59553) Thanks @Bluetegu.</li>
<li>Agents/sessions: add mailbox-style <code>sessions_list</code> filters for label, agent, and search plus visibility-scoped derived title and last-message previews. (#69839) Thanks @dangoZhang.</li>
<li>Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.</li>
<li>Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.</li>
<li>Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub onboarding, docs, <code>hy3-preview</code> model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.</li>
<li>Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo.</li>
<li>Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add <code>agents.defaults.promptOverlays.gpt5.personality</code> as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback.</li>
<li>Providers/OpenAI Codex: remove the Codex CLI auth import path from onboarding and provider discovery so OpenClaw no longer copies <code>~/.codex</code> OAuth material into agent auth stores; use browser login or device pairing instead. (#70390) Thanks @pashpashpash.</li>
<li>CLI/Claude: default <code>claude-cli</code> runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.</li>
<li>Pi/models: update the bundled pi packages to <code>0.68.1</code> and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed <code>opencode-go/kimi-k2.6</code>, Qwen, GLM, MiMo, and MiniMax entries.</li>
<li>Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy <code>exec</code> and <code>bash</code> tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.</li>
<li>ACPX: add an explicit <code>openClawToolsMcpBridge</code> option that injects a core OpenClaw MCP server for selected built-in tools, starting with <code>cron</code>.</li>
<li>CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin <code>dist/*</code> runtime entries over source-adjacent JavaScript fallbacks, reducing the measured <code>doctor --non-interactive</code> runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.</li>
<li>CLI/debugging: add an opt-in temporary debug timing helper for local CLI performance investigations, with readable stderr output, JSONL capture, and docs for removing probes before landing fixes. (#70469) Thanks @shakkernerd.</li>
<li>Docs/i18n: add Thai translation support for the docs site.</li>
<li>Providers/OpenAI-compatible: mark known local backends such as vLLM, SGLang, llama.cpp, LM Studio, LocalAI, Jan, TabbyAPI, and text-generation-webui as streaming-usage compatible, so their token accounting no longer degrades to unknown/stale totals. (#68711) Thanks @gaineyllc.</li>
<li>Providers/OpenAI-compatible: recover streamed token usage from llama.cpp-style <code>timings.prompt_n</code> / <code>timings.predicted_n</code> metadata and sanitize usage counts before accumulation, fixing unknown or stale totals when compatible servers do not emit an OpenAI-shaped <code>usage</code> object. (#41056) Thanks @xaeon2026.</li>
<li>Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.</li>
<li>Plugin SDK/STT: share realtime transcription WebSocket transport and multipart batch transcription form helpers across bundled STT providers, reducing provider plugin boilerplate while preserving proxy capture, reconnects, audio queueing, close flushing, upload filename normalization, and ready handshakes.</li>
<li>Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as <code>tool_result</code> handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.</li>
<li>Codex harness/hooks: route native Codex app-server turns through <code>before_prompt_build</code> and emit <code>before_compaction</code> / <code>after_compaction</code> for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async <code>tool_result</code> middleware, fire <code>after_tool_call</code> for Codex tool runs, and route mirrored Codex transcript writes through <code>before_message_write</code> so tool integrations stop diverging from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/hooks: fire <code>llm_input</code>, <code>llm_output</code>, and <code>agent_end</code> for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.</li>
<li>Status: add an explicit <code>Runner:</code> field to <code>/status</code> so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as <code>codex (acp/acpx)</code> or <code>gemini (acp/acpx)</code>. (#70595)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy <code>off</code>/<code>low</code> fallback behavior to a safe provider-supported <code>medium</code> equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make <code>/status</code> report the same resolved default as runtime.</li>
<li>Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses.</li>
<li>Agents/sessions: keep daily reset and idle-maintenance bookkeeping from bumping session activity or pruning freshly active routes, so active conversations no longer look newer or disappear for maintenance-only updates.</li>
<li>Plugins/install: add newly installed plugin ids to an existing <code>plugins.allow</code> list before enabling them, so allowlisted configs load installed plugins after restart.</li>
<li>Status: show <code>Fast</code> in <code>/status</code> when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.</li>
<li>OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure <code>api-key</code> auth plus deployment-scoped image URLs, honor <code>AZURE_OPENAI_API_VERSION</code>, and document the Azure setup path so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.</li>
<li>Telegram/forum topics: cache recovered forum metadata with bounded expiry so supergroup updates no longer need repeated <code>getChat</code> lookups before topic routing.</li>
<li>Onboarding/WeCom: show the official WeCom channel plugin with its native Enterprise WeChat display name and blurb in the external channel catalog.</li>
<li>Models/auth: merge provider-owned default-model additions from <code>openclaw models auth login</code> instead of replacing <code>agents.defaults.models</code>, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with <code>replaceDefaultModels</code>. Fixes #69414. (#70435) Thanks @neeravmakwana.</li>
<li>Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in <code>tools.media.audio</code> auto mode. Fixes #68727.</li>
<li>Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.</li>
<li>Agents/BTW: route <code>/btw</code> side questions through provider stream registration with the session workspace, so Ollama provider URL construction and workspace-scoped hooks apply correctly. Fixes #68336. (#70413) Thanks @suboss87.</li>
<li>Agents/sessions: make session transcript write locks non-reentrant by default, so same-process transcript writers contend unless a helper explicitly opts into nested lock ownership.</li>
<li>ACPX/probe: expose an optional <code>probeAgent</code> plugin config field so the embedded ACP runtime health probe can target a configured agent (for example <code>opencode</code> or <code>claude</code>) instead of hardcoding <code>codex</code>, and stop marking the entire ACP runtime backend unavailable when the default probe agent is simply not installed or not authenticated. (#68409) Thanks @lyfuci.</li>
<li>Memory search: use sqlite-vec KNN for vector recall while preserving full post-filter result limits in multi-model indexes. Fixes #69666. (#69680) Thanks @aalekh-sarvam.</li>
<li>Providers/OpenAI Codex: stop stale per-agent <code>openai-codex:default</code> OAuth profiles from shadowing a newer main-agent identity-scoped profile, and let <code>openclaw doctor</code> offer the matching cleanup. (#70393) Thanks @pashpashpash.</li>
<li>ACPX: route OpenClaw ACP bridge commands through the MCP-free runtime path even when the command is wrapped with <code>env</code>, has bridge flags, or is resumed from persisted session state, so documented <code>acpx openclaw</code> setups no longer fail on per-session MCP injection. (#68741) Thanks @alexlomt.</li>
<li>Codex harness: route Codex-tagged MCP tool approval elicitations through OpenClaw plugin approvals, including current empty-schema app-server requests, while leaving generic user-input prompts fail-closed. (#68807) Thanks @kesslerio.</li>
<li>WhatsApp/outbound: hold an in-memory active-delivery claim while a live outbound send is in flight, so a concurrent reconnect drain no longer re-drives the same pending queue entry and duplicates cron sends 7-12x after the 30-minute inbound-silence watchdog fires mid-delivery. Crash-replay of fresh queue entries left behind by a dead process is preserved because the claim is intentionally process-local. Fixes #70386. (#70428) Thanks @neeravmakwana.</li>
<li>Matrix/commands: keep Matrix DM allowlist state out of room control-command authorization, so trusted DM senders do not accidentally gain room-command access.</li>
<li>Providers/SDK retry: cap long <code>Retry-After</code> sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.</li>
<li>Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken <code>MEDIA:</code> or voice tags as delivery metadata. (#68869) Thanks @zqchris.</li>
<li>Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.</li>
<li>Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (<code>stopReason=stop</code>). Real failure modes (tool errors, provider <code>stopReason=error</code>, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.</li>
<li>Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny <code>/bin/sh</code> shim that raises the child's own <code>oom_score_adj</code> on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with <code>OPENCLAW_CHILD_OOM_SCORE_ADJ=0</code>. Fixes #70404. (#70419) Thanks @neeravmakwana.</li>
<li>Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like <code>functions.<name>:<index></code>) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a <code>sanitizeToolCallIds</code> opt-out to the shared <code>openai-compatible</code> replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.</li>
<li>Dependencies/security: override transitive <code>uuid</code> to <code>14.0.0</code>, clearing the runtime advisory across dependencies.</li>
<li>Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.</li>
<li>Codex harness: expose the Codex app-server model catalog in <code>models list/status</code>, avoid startup hangs from app-server discovery timeouts, and accept current Codex turn-completion notifications so Docker live gateway turns finish reliably.</li>
<li>Codex harness: drop invalid legacy app-server <code>serviceTier</code> values such as <code>"priority"</code> before native thread and turn requests, while keeping supported Codex tiers limited to <code>"fast"</code> and <code>"flex"</code>. Fixes #64815.</li>
<li>Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.</li>
<li>Docs/Codex harness: narrow native compaction docs to the current start/completion signals, without promising a readable summary or kept-entry audit list yet. (#69612) Thanks @91wan.</li>
<li>Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.</li>
<li>Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.</li>
<li>Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so <code>plugins install</code> and <code>plugins update</code> update an included <code>plugins.json5</code> file instead of flattening modular <code>$include</code> configs. Fixes #41050 and #66048.</li>
<li>Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.</li>
<li>Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed <code>plugins install</code> attempts at <code>plugins update</code> / <code>--force</code> instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.</li>
<li>Agents/MCP: keep <code>mcp.servers</code> and bundle MCP tools available in Pi embedded <code>coding</code> and <code>messaging</code> sessions while preserving <code>minimal</code> profile and <code>tools.deny: ["bundle-mcp"]</code> opt-out behavior. Fixes #68875 and #68818.</li>
<li>Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable.</li>
<li>CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125.</li>
<li>Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn.</li>
<li>Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new <code>Authorization</code> header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.</li>
<li>Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs.</li>
<li>Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding <code>config set --merge</code> for additive updates and <code>--replace</code> for intentional clobbers. Fixes #65920, #68392, and #68653.</li>
<li>Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.</li>
<li>Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing <code>gateway.mode</code>, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.</li>
<li>Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or <code>openclaw doctor --fix</code>, preserving the clobbered file as a backup while leaving normal config reads read-only.</li>
<li>Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.</li>
<li>Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into <code>createAgentSession</code>. Thanks @vincentkoc.</li>
<li>Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom <code>models.providers.openai.baseUrl</code> endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.</li>
<li>Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron <code>deleteAfterRun</code> fallback cleanup. Fixes #69145, #68623, and #68827.</li>
<li>MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465.</li>
<li>Agents/MCP: retire bundled MCP runtimes after completed one-shot subagent cleanup and nested <code>sessions_send</code> steps, while keeping persistent subagent sessions warm.</li>
<li>Config: render validation warnings with real line breaks instead of a literal <code>\n</code> sequence in CLI/audit output. Fixes #70140.</li>
<li>Cron/doctor: repair malformed persisted cron job IDs through <code>openclaw doctor</code>, including legacy <code>jobId</code>, non-string <code>id</code>, and missing <code>id</code> rows, so <code>cron list</code> no longer needs display-layer coercion for corrupt store data. Fixes #70128.</li>
<li>Discord: normalize prefixed channel targets only at the thread-binding API boundary, so <code>sessions_spawn({ runtime: "acp", thread: true })</code> can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.</li>
<li>Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when <code>name</code>, <code>parentId</code>, <code>parent</code>, or <code>ownerId</code> requires fetched raw data.</li>
<li>Discord: let <code>message</code> tool reactions resolve <code>user:<id></code> DM targets and preserve <code>channels.discord.guilds.<guild>.channels.<channel>.requireMention: false</code> during reply-stage activation fallback. Fixes #70165 and #69441.</li>
<li>Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.</li>
<li>ACP/Codex: run the bundled Codex ACP harness with an isolated <code>CODEX_HOME</code> and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88.</li>
<li>Gateway/client: keep long-running RPCs such as ACP <code>agent.wait</code> calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending.</li>
<li>Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james.</li>
<li>Telegram/polling: rebuild the polling HTTP transport after <code>getUpdates</code> 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys.</li>
<li>Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored <code>media://inbound/*</code> attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.</li>
<li>Slack/files: resolve <code>downloadFile</code> bot tokens from the runtime config when callers provide <code>cfg</code> without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.</li>
<li>Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.</li>
<li>Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.</li>
<li>CLI/Claude: keep stored Claude CLI sessions through OAuth refresh-token rotation by keying auth epochs on stable account identity instead of mutable OAuth token material. (#70452) Thanks @obviyus.</li>
<li>CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with <code>reason=transcript-missing</code> instead of silently starting fresh under <code>--resume</code>. Fixes #70177.</li>
<li>CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.</li>
<li>ACP/sessions_spawn: honor explicit <code>model</code> overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.</li>
<li>Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling <code>plugins.entries.diffs.config.security.allowRemoteViewer</code> closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>Diffs/tooling: re-read <code>viewerBaseUrl</code>, presentation defaults, and viewer access policy from live runtime config, and fail closed when the live <code>diffs</code> plugin entry disappears instead of reviving startup viewer settings. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: stop resurrecting removed live <code>memory-lancedb</code> hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: keep auto-recall and auto-capture hooks wired when those settings start disabled, so turning them on in live config starts recall and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Skill Workshop: keep the tool plus <code>before_prompt_build</code> / <code>agent_end</code> hooks wired while the plugin is disabled at startup, so turning the plugin back on in live config starts guidance and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Active Memory: stop reviving removed live <code>active-memory</code> config from startup snapshots, so removing the plugin entry turns the hook off immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>GitHub Copilot: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.github-copilot.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Ollama: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.ollama.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>OpenAI: re-read the plugin prompt-overlay personality from live runtime config, so GPT-5 system prompt contributions update without a restart when <code>plugins.entries.openai.config.personality</code> changes. Thanks @vincentkoc.</li>
<li>Amazon Bedrock: re-read live discovery and guardrail plugin config, so toggling <code>plugins.entries.amazon-bedrock.config.discovery</code> or <code>plugins.entries.amazon-bedrock.config.guardrail</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Codex: re-read the plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.codex.config.discovery</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Agents/subagents: drop bare <code>NO_REPLY</code> from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.</li>
<li>Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads.</li>
<li>CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash <code>openclaw status</code> or bootstrap secret scans.</li>
<li>Memory/LanceDB: retry initialization after a failed LanceDB load and report unsupported Intel macOS native runtime clearly instead of caching the failure or repeatedly attempting an install that cannot work.</li>
<li>CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.</li>
<li>Hooks/Slack: standardize shared message hook routing fields (<code>threadId</code> / <code>replyToId</code>) and stop Slack outbound delivery from re-running <code>message_sending</code> inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.</li>
<li>Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local <code>MEDIA:</code> attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.</li>
<li>Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed <code>gateway_start</code> hook, and move memory-core managed dreaming off the internal <code>gateway:startup</code> bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.</li>
<li>Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so <code>plugins.allow</code> remains enforced and <code>doctor</code>/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141.</li>
<li>Agents/openai-completions: enable malformed streamed tool-call argument repair for self-hosted OpenAI-compatible backends such as Kimi/SGLang, so fragmented tool-call arguments no longer reach tools as empty or unusable objects. Fixes #69672. (#70294) Thanks @MonkeyLeeT.</li>
<li>Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.</li>
<li>Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve <code>metadata-upgrade</code> pairing (platform / device family refresh) instead of being disconnected with <code>1008 pairing required</code>. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.</li>
<li>Gateway/pairing: treat any forwarded-header evidence (<code>Forwarded</code>, <code>X-Forwarded-*</code>, or <code>X-Real-IP</code>) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.</li>
<li>Agents/OpenAI: treat exact <code>NO_REPLY</code> assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.</li>
<li>Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware <code>final_answer</code> delivery, so split <code>MEDIA:<path></code> lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.</li>
<li>Anthropic/Claude Opus 4.7: normalize Opus 4.7 and <code>claude-cli</code> Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.</li>
<li>Gateway/pairing webchat: render <code>/pair qr</code> replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.</li>
<li>Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.</li>
<li>Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.</li>
<li>Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so <code>codex/gpt-5.x</code> sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.</li>
<li>Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy <code>OPENCLAW_CODEX_APP_SERVER_GUARDIAN</code> shortcut is removed; use plugin config <code>appServer.mode: "guardian"</code> or <code>OPENCLAW_CODEX_APP_SERVER_MODE=guardian</code>. Thanks @pashpashpash.</li>
<li>OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when <code>models.providers.openai.baseUrl</code> points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.</li>
<li>Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper <code>loadConfig()</code> calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.</li>
<li>Discord: pass resolved runtime config through guild and moderation action helpers, so thread-originated Discord commands can run channel, member, role, and guild actions without falling back to runtime config reads. (#70215) Thanks @szponeczek.</li>
<li>CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram <code>streaming</code> into <code>accounts.default</code>.</li>
<li>Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom <code>session.store</code> paths.</li>
<li>Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in <code>openclaw update --json</code>.</li>
<li>Ollama: forward OpenClaw thinking control to native <code>/api/chat</code> requests as top-level <code>think</code>, so <code>/think off</code> and <code>openclaw agent --thinking off</code> suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.</li>
<li>Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.</li>
<li>Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted <code>> Reasoning:</code> text, preventing <code>/reasoning on</code> from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.</li>
<li>Discord: read <code>channel.parentId</code> through a safe accessor in the slash-command, reaction, and model-picker paths so partial <code>GuildThreadChannel</code> prototype getters no longer throw <code>Cannot access rawData on partial Channel</code> when commands like <code>/new</code> run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.</li>
<li>Discord: use safe channel name and parent accessors across voice command authorization, so <code>/vc</code> commands from partial Discord thread channels no longer crash on Carbon rawData getters. (#70199) Thanks @hanamizuki.</li>
<li>Discord: make auto-thread parent transcript inheritance opt-in via <code>channels.discord.thread.inheritParent</code>, keeping newly created Discord thread sessions isolated by default while preserving explicit inheritance for configured accounts. Fixes #69907. (#69986) Thanks @Blahdude.</li>
<li>Browser/Chrome MCP: reset cached existing-session control sessions when a <code>navigate_page</code> call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.</li>
<li>Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.</li>
<li>Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.</li>
<li>Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.</li>
<li>OpenCode Go: canonicalize stale bundled <code>opencode-go</code> base URLs from <code>/go</code> or <code>/go/v1</code> to <code>/zen/go</code> or <code>/zen/go/v1</code>, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)</li>
<li>CLI/channels: honor <code>channels.<id>.enabled=false</code> as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.</li>
<li>Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.</li>
<li>Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long <code>/status</code> output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.</li>
<li>Gateway/session history: re-check current auth and <code>chat.history</code> scope before later SSE keepalives and transcript updates, so active session-history streams close before delivering post-revocation events.</li>
<li>Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.</li>
<li>CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.</li>
<li>Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so <code>openclaw doctor</code> and post-update doctor runs no longer crash with <code>Cannot read properties of undefined (reading 'clear')</code>. (#70135) Thanks @ngutman.</li>
<li>Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.</li>
<li>memory-core/dreaming: surface a <code>Dreaming status: blocked</code> line in <code>openclaw memory status</code> when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent <code>heartbeat</code> blocks excluding <code>main</code>, and <code>heartbeat.every</code> set to <code>0</code>/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.</li>
<li>Cron/run-log: report generic <code>message</code> tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.</li>
<li>Doctor/channels: merge configured-channel doctor hooks across read-only, loaded, setup, and runtime plugin discovery so partial adapters no longer hide runtime-only compatibility repair or allowlist warnings, preserve disabled-channel opt-outs, and ignore malformed hook values before they can mask valid fallbacks. (#69919) Thanks @gumadeiras.</li>
<li>Models/CLI: show bundled provider-owned static catalog rows in <code>models list --all</code> before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd.</li>
<li>Models/CLI: clarify that <code>models list --provider</code> expects provider ids and reject display labels before loading model discovery. (#70504) Thanks @shakkernerd.</li>
<li>Configure: skip generic CLI startup bootstrap for <code>openclaw configure</code> and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus.</li>
<li>Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects.</li>
<li>OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.</li>
<li>CLI sessions: keep provider-owned CLI sessions through implicit daily expiry while preserving explicit reset behavior, and retain Claude CLI binding metadata across gateway agent requests. (#70106) Thanks @obviyus.</li>
<li>fix(config): accept truncateAfterCompaction (#68395). Thanks @MonkeyLeeT</li>
<li>CLI/Claude: keep Claude CLI session bindings stable across OAuth access-token refreshes, so gateway restarts continue the same Claude conversation instead of minting a fresh one. (#70132) Thanks @obviyus.</li>
<li>QQBot: add <code>INTERACTION</code> intent (<code>1 << 26</code>) to the gateway constants and include it in the <code>FULL_INTENTS</code> mask so interaction events are received. (#70143) Thanks @cxyhhhhh.</li>
<li>Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc.</li>
<li>Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.</li>
<li>Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.</li>
<li>Security/dotenv: block workspace <code>.env</code> overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.</li>
<li>Telegram: require the same <code>/models</code> authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.</li>
<li>Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi <code>0.68.1</code> session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.</li>
<li>Agents/Pi: honor explicit <code>strict-agentic</code> execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.</li>
<li>OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.</li>
<li>Gateway/Control UI: require authenticated Control UI read access before serving <code>/__openclaw/control-ui-config.json</code> when <code>gateway.auth</code> is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.</li>
<li>Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus.</li>
<li>Build/npm publish: fail postpublish verification when root <code>dist/*</code> files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns.</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.22/OpenClaw-2026.4.22.zip" length="47883836" type="application/octet-stream" sparkle:edSignature="kzJ2j2sWX4H+ZIc4dXEFORYr9tk3w1txpjCJ38cdSFz6yWHU0M6Sx9zN0DB7JGIpv1QC+D+jFbWBkl4SJqW2AA=="/>
</item>
<item>
<title>2026.4.20</title>
<pubDate>Tue, 21 Apr 2026 19:53:52 +0000</pubDate>
@@ -363,5 +114,207 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.20/OpenClaw-2026.4.20.zip" length="47535600" type="application/octet-stream" sparkle:edSignature="D7XcNGxmc10IIayYY91RZBoascFSnXyd4dg6cSpC3+PTIwVrWYs/FwSBc/1J+1P53LlnTHKDGQYMkWVNMnRSAQ=="/>
</item>
<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>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026041490</sparkle:version>
<sparkle:shortVersionString>2026.4.14</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.14</h2>
<h3>Changes</h3>
<ul>
<li>OpenAI Codex/models: add forward-compat support for <code>gpt-5.4-pro</code>, including Codex pricing/limits and list/status visibility before the upstream catalog catches up. (#66453) Thanks @jepson-liu.</li>
<li>Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.</li>
<li>Models/Codex: include <code>apiKey</code> in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in <code>models.json</code>. (#66180) Thanks @hoyyeva.</li>
<li>Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.</li>
<li>Slack/interactions: apply the configured global <code>allowFrom</code> owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a <code>users</code> list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit.</li>
<li>Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via <code>realpath</code>, so a <code>realpath</code> error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit.</li>
<li>Agents/gateway-tool: reject <code>config.patch</code> and <code>config.apply</code> calls from the model-facing gateway tool when they would newly enable any flag enumerated by <code>openclaw security audit</code> (for example <code>dangerouslyDisableDeviceAuth</code>, <code>allowInsecureAuth</code>, <code>dangerouslyAllowHostHeaderOriginFallback</code>, <code>hooks.gmail.allowUnsafeExternalContent</code>, <code>tools.exec.applyPatch.workspaceOnly: false</code>); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit.</li>
<li>Google image generation: strip a trailing <code>/openai</code> suffix from configured Google base URLs only when calling the native Gemini image API so Gemini image requests stop 404ing without breaking explicit OpenAI-compatible Google endpoints. (#66445) Thanks @dapzthelegend.</li>
<li>Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus.</li>
<li>Doctor/systemd: keep <code>openclaw doctor --repair</code> and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir <code>.env</code> values. (#66249) Thanks @tmimmanuel.</li>
<li>Ollama/OpenAI-compat: send <code>stream_options.include_usage</code> for Ollama streaming completions so local Ollama runs report real usage instead of falling back to bogus prompt-token counts that trigger premature compaction. (#64568) Thanks @xchunzhao and @vincentkoc.</li>
<li>Doctor/plugins: cache external <code>preferOver</code> catalog lookups within each plugin auto-enable pass so large <code>agents.list</code> configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge.</li>
<li>GitHub Copilot/thinking: allow <code>github-copilot/gpt-5.4</code> to use <code>xhigh</code> reasoning so Copilot GPT-5.4 matches the rest of the GPT-5.4 family. (#50168) Thanks @jakepresent and @vincentkoc.</li>
<li>Memory/embeddings: preserve non-OpenAI provider prefixes when normalizing OpenAI-compatible embedding model refs so proxy-backed memory providers stop failing with <code>Unknown memory embedding provider</code>. (#66452) Thanks @jlapenna.</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>Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP <code>/json/new</code> fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus.</li>
<li>Models/Codex: canonicalize the legacy <code>openai-codex/gpt-5.4-codex</code> runtime alias to <code>openai-codex/gpt-5.4</code> while still honoring alias-specific and canonical per-model overrides. (#43060) Thanks @Sapientropic and @vincentkoc.</li>
<li>Browser/SSRF: preserve explicit strict browser navigation mode for legacy <code>browser.ssrfPolicy.allowPrivateNetwork: false</code> configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.</li>
<li>Onboarding/custom providers: use <code>max_tokens=16</code> for OpenAI-compatible verification probes so stricter custom endpoints stop rejecting onboarding checks that only need a tiny completion. (#66450) Thanks @WuKongAI-CMU.</li>
<li>Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with <code>ERR_MODULE_NOT_FOUND</code> at runtime. (#66420) Thanks @obviyus.</li>
<li>Media-understanding/proxy env: auto-upgrade provider HTTP helper requests to trusted env-proxy mode only when <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code> is active and the target is not bypassed by <code>NO_PROXY</code>, so remote media-understanding and transcription requests stop failing local DNS pre-resolution in proxy-only environments without widening SSRF bypasses. (#52162) Thanks @mjamiv and @vincentkoc.</li>
<li>Telegram/media downloads: let Telegram media fetches trust an operator-configured explicit proxy for target DNS resolution after hostname-policy checks, so proxy-backed installs stop failing <code>could not download media</code> on Bot API file downloads after the DNS-pinning regression. (#66245) Thanks @dawei41468 and @vincentkoc.</li>
<li>Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.</li>
<li>Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when <code>afterTurn</code> is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.</li>
<li>OpenAI Codex/auth: keep malformed Codex CLI auth-file diagnostics on the debug logger instead of stdout so interactive command output stays clean while auth read failures remain traceable. (#66451) Thanks @SimbaKingjoe.</li>
<li>Discord/native commands: return the real status card for native <code>/status</code> interactions instead of falling through to the synthetic <code>✅ Done.</code> ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.</li>
<li>Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit <code>agents.defaults.timeoutSeconds</code> override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.</li>
<li>Media/transcription: remap <code>.aac</code> filenames to <code>.m4a</code> for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.</li>
<li>UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.</li>
<li>Auto-reply/send policy: keep <code>sendPolicy: "deny"</code> from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.</li>
<li>BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.</li>
<li>Heartbeat/security: force owner downgrade for untrusted <code>hook:wake</code> system events [AI-assisted]. (#66031) Thanks @pgondhi987.</li>
<li>Browser/security: enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987.</li>
<li>Microsoft Teams/security: enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987.</li>
<li>Config/security: redact <code>sourceConfig</code> and <code>runtimeConfig</code> alias fields in <code>redactConfigSnapshot</code> [AI]. (#66030) Thanks @pgondhi987.</li>
<li>Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) Thanks @100yenadmin.</li>
<li>Plugins/status: report the registered context-engine IDs in <code>plugins inspect</code> instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) Thanks @zhuisDEV.</li>
<li>Context engines: reject resolved plugin engines whose reported <code>info.id</code> does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.</li>
<li>WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient <code>ENOENT</code> crashes on image sends. (#65896) Thanks @frankekn.</li>
<li>Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale <code>dist/entry.js</code> and current <code>dist/index.js</code> paths. (#65984) Thanks @mbelinky.</li>
<li>Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when <code>target=last</code>, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.</li>
<li>Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.</li>
<li>Gateway/sessions: stop heartbeat, cron-event, and exec-event turns from overwriting shared-session routing and origin metadata, preventing synthetic <code>heartbeat</code> targets from poisoning later cron or user delivery. (#66073, #63733, #35300) Thanks @mbelinky.</li>
<li>Browser/CDP: let local attach-only <code>manual-cdp</code> profiles reuse the local loopback CDP control plane under strict default policy and remote-class probe timeouts, so tabs/snapshot stop falsely reporting a live local browser session as not running. (#65611, #66080) Thanks @mbelinky.</li>
<li>Cron/scheduler: stop inventing short retries when cron next-run calculation returns no valid future slot, and keep a maintenance wake armed so enabled unscheduled jobs recover without entering a refire loop. (#66019, #66083) Thanks @mbelinky.</li>
<li>Cron/scheduler: preserve the active error-backoff floor when maintenance repair recomputes a missing cron next-run, so recurring errored jobs do not resume early after a transient next-run resolution failure. (#66019, #66083, #66113) Thanks @mbelinky.</li>
<li>Outbound/delivery-queue: persist the originating outbound <code>session</code> context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit.</li>
<li>Memory/Ollama: restore the built-in <code>ollama</code> embedding adapter in memory-core so explicit <code>memorySearch.provider: "ollama"</code> works again, and include endpoint-aware cache keys so different Ollama hosts do not reuse each other's embeddings. (#63429, #66078, #66163) Thanks @nnish16 and @vincentkoc.</li>
<li>Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.</li>
<li>Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.</li>
<li>Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional <code>memory-wiki</code> gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.</li>
<li>Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob.</li>
<li>Memory/active-memory: move recalled memory onto the hidden untrusted prompt-prefix path instead of system prompt injection, label the visible Active Memory status line fields, and include the resolved recall provider/model in gateway debug logs so trace/debug output matches what the model actually saw. (#66144) Thanks @Takhoffman.</li>
<li>Memory/QMD: stop treating legacy lowercase <code>memory.md</code> as a second default root collection, so QMD recall no longer searches phantom <code>memory-alt-*</code> collections and builtin/QMD root-memory fallback stays aligned. (#66141) Thanks @mbelinky.</li>
<li>Agents/subagents: ship <code>dist/agents/subagent-registry.runtime.js</code> in npm builds so <code>runtime: "subagent"</code> runs stop stalling in <code>queued</code> after the registry import fails. (#66189) Thanks @yqli2420 and @vincentkoc.</li>
<li>Agents/OpenAI: map <code>minimal</code> thinking to OpenAI's supported <code>low</code> reasoning effort for GPT-5.4 requests, so embedded runs stop failing request validation. Thanks @steipete.</li>
<li>Voice-call/media-stream: resolve the source IP from trusted forwarding headers for per-IP pending-connection limits when <code>webhookSecurity.trustForwardingHeaders</code> and <code>trustedProxyIPs</code> are configured, and reserve <code>maxConnections</code> capacity for in-flight WebSocket upgrades so concurrent handshakes can no longer momentarily exceed the operator-set cap. (#66027) Thanks @eleqtrizit.</li>
<li>Feishu/allowlist: canonicalize allowlist entries by explicit <code>user</code>/<code>chat</code> kind, strip repeated <code>feishu:</code>/<code>lark:</code> provider prefixes, and stop folding opaque Feishu IDs to lowercase, so allowlist matching no longer crosses user/chat namespaces or widens to case-insensitive ID matches the operator did not intend. (#66021) Thanks @eleqtrizit.</li>
<li>Telegram/status commands: let read-only status slash commands bypass busy topic turns, while keeping <code>/export-session</code> on the normal lane so it cannot interleave with an in-flight session mutation. (#66226) Thanks @VACInc and @vincentkoc.</li>
<li>TTS/reply media: persist OpenClaw temp voice outputs into managed outbound media and allow them through reply-media normalization, so voice-note replies stop silently dropping. (#63511) Thanks @jetd1.</li>
<li>Agents/tools: treat Windows drive-letter paths (<code>C:\\...</code>) as absolute when resolving sandbox and read-tool paths so workspace root is not prepended under POSIX path rules. (#54039) Thanks @ly85206559 and @vincentkoc.</li>
<li>Agents/OpenAI: recover embedded GPT-style runs when reasoning-only or empty turns need bounded continuation, with replay-safe retry gating and incomplete-turn fallback when no visible answer arrives. (#66167) thanks @jalehman</li>
<li>Outbound/relay-status: suppress internal relay-status placeholder payloads (<code>No channel reply.</code>, <code>Replied in-thread.</code>, <code>Replied in #...</code>, wiki-update status variants ending in <code>No channel reply.</code>) before channel delivery so internal housekeeping text does not leak to users.</li>
<li>Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as <code>openclaw cron</code> no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.</li>
<li>Hooks/session-memory: pass the resolved agent workspace into gateway <code>/new</code> and <code>/reset</code> session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.</li>
<li>CLI/approvals: raise the default <code>openclaw approvals get</code> gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading <code>Config unavailable.</code> note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.</li>
<li>Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.</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.14/OpenClaw-2026.4.14.zip" length="47490719" type="application/octet-stream" sparkle:edSignature="KW4gq3qjhKPSQebRVL/mSgttTOhLVKtnWz7pNCZt29oEZ96yU14OnxxSsmtNHmDi4m7G7gfVOfndp80XKFQlCw=="/>
</item>
</channel>
</rss>

View File

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

View File

@@ -3,7 +3,6 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
@@ -53,7 +52,7 @@
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone" />
android:foregroundServiceType="dataSync" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"

View File

@@ -34,7 +34,7 @@ fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
AssistantLaunchRequest(
source = "app_action",
prompt = prompt,
autoSend = false,
autoSend = prompt != null,
)
}

View File

@@ -101,8 +101,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
@@ -112,10 +111,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
val talkModeEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeEnabled }
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
@@ -288,10 +283,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().setMicEnabled(enabled)
}
fun setTalkModeEnabled(enabled: Boolean) {
ensureRuntime().setTalkModeEnabled(enabled)
}
fun setSpeakerEnabled(enabled: Boolean) {
ensureRuntime().setSpeakerEnabled(enabled)
}

View File

@@ -3,14 +3,12 @@ package ai.openclaw.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -23,7 +21,6 @@ class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var didStartForeground = false
private var voiceCaptureMode = VoiceCaptureMode.Off
override fun onCreate() {
super.onCreate()
@@ -39,51 +36,22 @@ class NodeForegroundService : Service() {
notificationJob =
scope.launch {
combine(
combine(
runtime.statusText,
runtime.serverName,
runtime.isConnected,
runtime.voiceCaptureMode,
) { status, server, connected, mode ->
VoiceNotificationBase(
status = status,
server = server,
connected = connected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}.collect { state ->
voiceCaptureMode = state.mode
val title =
when {
state.connected && state.mode == VoiceCaptureMode.TalkMode -> "OpenClaw Node · Talk"
state.connected -> "OpenClaw Node · Connected"
else -> "OpenClaw Node"
runtime.statusText,
runtime.serverName,
runtime.isConnected,
runtime.micEnabled,
runtime.micIsListening,
) { status, server, connected, micEnabled, micListening ->
Quint(status, server, connected, micEnabled, micListening)
}.collect { (status, server, connected, micEnabled, micListening) ->
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
val micSuffix =
if (micEnabled) {
if (micListening) " · Mic: Listening" else " · Mic: Pending"
} else {
""
}
val text =
(state.server?.let { "${state.status} · $it" } ?: state.status) +
voiceNotificationSuffix(
mode = state.mode,
manualMicEnabled = state.capture.micEnabled,
manualMicListening = state.capture.micListening,
talkListening = state.capture.talkListening,
talkSpeaking = state.capture.talkSpeaking,
)
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
@@ -92,27 +60,13 @@ class NodeForegroundService : Service() {
}
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
(application as NodeApp).peekRuntime()?.disconnect()
stopSelf()
return START_NOT_STICKY
}
ACTION_SET_VOICE_CAPTURE_MODE -> {
voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode()
startForegroundWithTypes(
notification =
buildNotification(
title = "OpenClaw Node",
text = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) "Talk mode active" else "Connected",
),
)
}
}
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
return START_STICKY
@@ -173,13 +127,17 @@ class NodeForegroundService : Service() {
.build()
}
private fun updateNotification(notification: Notification) {
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mgr.notify(NOTIFICATION_ID, notification)
}
private fun startForegroundWithTypes(notification: Notification) {
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
if (didStartForeground) {
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
updateNotification(notification)
return
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
didStartForeground = true
}
@@ -188,8 +146,6 @@ class NodeForegroundService : Service() {
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
@@ -200,85 +156,7 @@ class NodeForegroundService : Service() {
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
) {
val intent =
Intent(context, NodeForegroundService::class.java)
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
if (mode == VoiceCaptureMode.TalkMode) {
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
}
}
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
return if (mode == VoiceCaptureMode.TalkMode) {
base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
base
}
}
internal fun voiceNotificationSuffix(
mode: VoiceCaptureMode,
manualMicEnabled: Boolean,
manualMicListening: Boolean,
talkListening: Boolean,
talkSpeaking: Boolean,
): String {
return when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> " · Talk: Speaking"
talkListening -> " · Talk: Listening"
else -> " · Talk: On"
}
VoiceCaptureMode.ManualMic ->
if (manualMicEnabled) {
if (manualMicListening) " · Mic: Listening" else " · Mic: Pending"
} else {
""
}
VoiceCaptureMode.Off -> ""
}
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode {
return VoiceCaptureMode.entries.firstOrNull { it.name == this } ?: VoiceCaptureMode.Off
}
private data class VoiceNotificationBase(
val status: String,
val server: String?,
val connected: Boolean,
val mode: VoiceCaptureMode,
)
private data class VoiceNotificationCapture(
val micEnabled: Boolean,
val micListening: Boolean,
val talkListening: Boolean,
val talkSpeaking: Boolean,
)
private data class VoiceNotificationState(
val base: VoiceNotificationBase,
val capture: VoiceNotificationCapture,
) {
val status: String
get() = base.status
val server: String?
get() = base.server
val connected: Boolean
get() = base.connected
val mode: VoiceCaptureMode
get() = base.mode
}
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)

View File

@@ -64,8 +64,6 @@ class NodeRuntime(
private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false)
private val _voiceCaptureMode = MutableStateFlow(VoiceCaptureMode.Off)
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = _voiceCaptureMode.asStateFlow()
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
@@ -430,18 +428,6 @@ class NodeRuntime(
)
}
val talkModeEnabled: StateFlow<Boolean>
get() = talkMode.isEnabled
val talkModeListening: StateFlow<Boolean>
get() = talkMode.isListening
val talkModeSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
val talkModeStatusText: StateFlow<String>
get() = talkMode.statusText
private fun syncMainSessionKey(agentId: String?) {
val resolvedKey = resolveNodeMainSessionKey(agentId)
// Always push the resolved session key into TalkMode, even when the
@@ -613,8 +599,17 @@ class NodeRuntime(
prefs.loadGatewayToken()
}
if (prefs.voiceMicEnabled.value) {
setVoiceCaptureMode(VoiceCaptureMode.ManualMic, persistManualMic = false)
scope.launch {
prefs.talkEnabled.collect { enabled ->
// MicCaptureManager handles STT + send to gateway, while the dedicated
// reply speaker handles TTS for assistant replies in the voice tab.
micCapture.setMicEnabled(enabled)
if (enabled) {
talkMode.ttsOnAllResponses = false
scope.launch { talkMode.ensureChatSubscribed() }
}
externalAudioCaptureActive.value = enabled
}
}
scope.launch(Dispatchers.Default) {
@@ -648,7 +643,7 @@ class NodeRuntime(
if (value) {
reconnectPreferredGatewayOnForeground()
} else {
stopManualVoiceSession()
stopActiveVoiceSession()
}
}
@@ -762,17 +757,21 @@ class NodeRuntime(
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
stopManualVoiceSession()
stopActiveVoiceSession()
}
// Don't re-enable on active=true; mic toggle drives that
}
fun setMicEnabled(value: Boolean) {
setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off)
}
fun setTalkModeEnabled(value: Boolean) {
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
prefs.setTalkEnabled(value)
if (value) {
// Tapping mic on interrupts any active TTS (barge-in)
stopVoicePlayback()
talkMode.ttsOnAllResponses = false
scope.launch { talkMode.ensureChatSubscribed() }
}
micCapture.setMicEnabled(value)
externalAudioCaptureActive.value = value
}
val speakerEnabled: StateFlow<Boolean>
@@ -787,72 +786,11 @@ class NodeRuntime(
talkMode.setPlaybackEnabled(value)
}
private fun setVoiceCaptureMode(
mode: VoiceCaptureMode,
persistManualMic: Boolean = true,
) {
if (mode == VoiceCaptureMode.TalkMode && !hasRecordAudioPermission()) {
_voiceCaptureMode.value = VoiceCaptureMode.Off
externalAudioCaptureActive.value = false
return
}
if (_voiceCaptureMode.value == mode) return
_voiceCaptureMode.value = mode
when (mode) {
VoiceCaptureMode.Off -> {
talkMode.ttsOnAllResponses = false
talkMode.setEnabled(false)
stopVoicePlayback()
micCapture.setMicEnabled(false)
if (persistManualMic) {
prefs.setVoiceMicEnabled(false)
}
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
externalAudioCaptureActive.value = false
}
VoiceCaptureMode.ManualMic -> {
talkMode.ttsOnAllResponses = false
talkMode.setEnabled(false)
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.ManualMic)
if (persistManualMic) {
prefs.setVoiceMicEnabled(true)
}
// Tapping mic on interrupts any active TTS (barge-in).
stopVoicePlayback()
scope.launch { talkMode.ensureChatSubscribed() }
micCapture.setMicEnabled(true)
externalAudioCaptureActive.value = true
}
VoiceCaptureMode.TalkMode -> {
if (persistManualMic) {
prefs.setVoiceMicEnabled(false)
}
micCapture.setMicEnabled(false)
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.TalkMode)
talkMode.ttsOnAllResponses = true
talkMode.setPlaybackEnabled(speakerEnabled.value)
scope.launch { talkMode.ensureChatSubscribed() }
talkMode.setEnabled(true)
externalAudioCaptureActive.value = true
}
}
}
private fun stopManualVoiceSession() {
if (_voiceCaptureMode.value != VoiceCaptureMode.ManualMic) return
setVoiceCaptureMode(VoiceCaptureMode.Off)
}
private fun stopActiveVoiceSession() {
talkMode.ttsOnAllResponses = false
talkMode.setEnabled(false)
stopVoicePlayback()
micCapture.setMicEnabled(false)
prefs.setVoiceMicEnabled(false)
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
_voiceCaptureMode.value = VoiceCaptureMode.Off
prefs.setTalkEnabled(false)
externalAudioCaptureActive.value = false
}
@@ -1032,7 +970,6 @@ class NodeRuntime(
}
fun disconnect() {
stopActiveVoiceSession()
connectedEndpoint = null
activeGatewayAuth = null
_pendingGatewayTrust.value = null

View File

@@ -37,7 +37,6 @@ class SecurePrefs(
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
private const val voiceMicEnabledKey = "voice.micEnabled"
}
private val appContext = context.applicationContext
@@ -163,8 +162,8 @@ class SecurePrefs(
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
private val _voiceMicEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceMicEnabledKey, false))
val voiceMicEnabled: StateFlow<Boolean> = _voiceMicEnabled
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
@@ -479,9 +478,9 @@ class SecurePrefs(
_voiceWakeMode.value = mode
}
fun setVoiceMicEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceMicEnabledKey, value) }
_voiceMicEnabled.value = value
fun setTalkEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
fun setSpeakerEnabled(value: Boolean) {

View File

@@ -1,7 +0,0 @@
package ai.openclaw.app
enum class VoiceCaptureMode {
Off,
ManualMic,
TalkMode,
}

View File

@@ -63,6 +63,8 @@ internal fun isPrivateLanGatewayHost(
}
if (host.isEmpty()) return false
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
if (host.endsWith(".local")) return true
if (!host.contains('.') && !host.contains(':')) return true
parseIpv4Address(host)?.let { ipv4 ->
val first = ipv4[0].toInt() and 0xff

View File

@@ -7,7 +7,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo
import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import ai.openclaw.app.LocationMode
import ai.openclaw.app.VoiceWakeMode
@@ -34,7 +34,7 @@ class ConnectionManager(
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host)
if (isManual) {
if (!manualTlsEnabled && cleartextAllowedHost) return null

View File

@@ -1,6 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import java.util.Base64
import java.util.Locale
import java.net.URI
@@ -56,9 +56,9 @@ internal data class GatewayScannedSetupCodeResult(
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
private const val remoteGatewaySecurityRule =
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator."
private const val remoteGatewaySecurityFix =
"Use localhost/the Android emulator, or enable Tailscale Serve / expose a wss:// gateway URL."
"Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL."
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
@@ -143,7 +143,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) {
if (!tls && !isPrivateLanGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =

View File

@@ -49,7 +49,7 @@ internal fun buildGatewayDiagnosticsReport(
Please:
- pick one route only: same machine, same LAN, Tailscale, or public URL
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; ws:// is loopback-only
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; private LAN ws:// is still allowed
- quote the exact app status/error below
- tell me whether `openclaw devices list` should show a pending pairing request
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`

View File

@@ -35,11 +35,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@@ -70,7 +69,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.voice.VoiceConversationEntry
import ai.openclaw.app.voice.VoiceConversationRole
import kotlin.math.max
@@ -83,7 +81,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val gatewayStatus by viewModel.statusText.collectAsState()
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val micCooldown by viewModel.micCooldown.collectAsState()
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
@@ -93,15 +90,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val micConversation by viewModel.micConversation.collectAsState()
val micInputLevel by viewModel.micInputLevel.collectAsState()
val micIsSending by viewModel.micIsSending.collectAsState()
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
var pendingVoicePermissionAction by remember { mutableStateOf<PendingVoicePermissionAction?>(null) }
var pendingMicEnable by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner, context) {
val observer =
@@ -113,7 +107,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// Manual mic is tied to the Voice tab; Talk Mode is explicit and can continue.
// Stop TTS when leaving the voice screen
viewModel.setVoiceScreenActive(false)
}
}
@@ -121,14 +115,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val requestMicPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPermission = granted
if (granted) {
when (pendingVoicePermissionAction) {
PendingVoicePermissionAction.ManualMic -> viewModel.setMicEnabled(true)
PendingVoicePermissionAction.TalkMode -> viewModel.setTalkModeEnabled(true)
null -> Unit
}
if (granted && pendingMicEnable) {
viewModel.setMicEnabled(true)
}
pendingVoicePermissionAction = null
pendingMicEnable = false
}
LaunchedEffect(micConversation.size, showThinkingBubble) {
@@ -171,12 +161,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
tint = mobileTextTertiary,
)
Text(
"Tap mic or Talk",
"Tap the mic to start",
style = mobileHeadline,
color = mobileTextSecondary,
)
Text(
"Mic sends turns; Talk keeps the conversation open.",
"Each pause sends a turn automatically.",
style = mobileCallout,
color = mobileTextTertiary,
)
@@ -273,7 +263,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
if (hasMicPermission) {
viewModel.setMicEnabled(true)
} else {
pendingVoicePermissionAction = PendingVoicePermissionAction.ManualMic
pendingMicEnable = true
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
@@ -297,39 +287,11 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = {
if (talkModeEnabled) {
viewModel.setTalkModeEnabled(false)
return@IconButton
}
if (hasMicPermission) {
viewModel.setTalkModeEnabled(true)
} else {
pendingVoicePermissionAction = PendingVoicePermissionAction.TalkMode
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
modifier = Modifier.size(48.dp),
colors =
IconButtonDefaults.iconButtonColors(
containerColor = if (talkModeEnabled) mobileSuccessSoft else mobileSurface,
),
) {
Icon(
imageVector = Icons.Default.RecordVoiceOver,
contentDescription = if (talkModeEnabled) "Turn Talk Mode off" else "Turn Talk Mode on",
modifier = Modifier.size(22.dp),
tint = if (talkModeEnabled) mobileSuccess else mobileTextSecondary,
)
}
// Invisible spacer to balance the row (matches speaker column width)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
if (talkModeEnabled) "Talk on" else "Talk",
style = mobileCaption2,
color = if (talkModeEnabled) mobileSuccess else mobileTextTertiary,
)
Text("", style = mobileCaption2)
}
}
@@ -337,9 +299,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val queueCount = micQueuedMessages.size
val stateText =
when {
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micCooldown -> "Cooldown"
@@ -348,15 +307,14 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
val stateColor =
when {
voiceCaptureMode == VoiceCaptureMode.TalkMode -> mobileSuccess
micEnabled -> mobileSuccess
micIsSending -> mobileAccent
else -> mobileTextSecondary
}
Surface(
shape = RoundedCornerShape(999.dp),
color = if (micEnabled || talkModeEnabled) mobileSuccessSoft else mobileSurface,
border = BorderStroke(1.dp, if (micEnabled || talkModeEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder),
color = if (micEnabled) mobileSuccessSoft else mobileSurface,
border = BorderStroke(1.dp, if (micEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder),
) {
Text(
"$gatewayStatus · $stateText",
@@ -395,11 +353,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
private enum class PendingVoicePermissionAction {
ManualMic,
TalkMode,
}
@Composable
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
val isUser = entry.role == VoiceConversationRole.User

View File

@@ -226,15 +226,14 @@ class TalkModeManager(
// If this is a response we initiated, handle normally below.
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
val pending = pendingRunId
val knownRun = pending == runId || hasRunCompletion(runId)
if (!knownRun) {
if (pending == null || runId != pending) {
if (ttsOnAllResponses && state == "final") {
val text = extractTextFromChatEventMessage(obj["message"])
if (!text.isNullOrBlank()) {
playTtsForText(text)
}
}
return
if (pending == null || runId != pending) return
}
Log.d(tag, "chat event arrived runId=$runId state=$state pendingRunId=$pendingRunId")
val terminal =
@@ -540,7 +539,6 @@ class TalkModeManager(
private suspend fun sendChat(message: String, session: GatewaySession): String {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
@@ -549,29 +547,19 @@ class TalkModeManager(
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId))
}
try {
val res = session.request("chat.send", params.toString())
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
}
return parsed
} catch (err: Throwable) {
clearPendingRun(runId)
throw err
val res = session.request("chat.send", params.toString())
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
}
return parsed
}
private suspend fun waitForChatFinal(runId: String): Boolean {
consumeRunCompletion(runId)?.let { return it }
val deferred =
if (pendingRunId == runId) {
pendingFinal ?: armPendingRun(runId)
} else {
armPendingRun(runId)
}
consumeRunCompletion(runId)?.let { return it }
pendingFinal?.cancel()
val deferred = CompletableDeferred<Boolean>()
pendingRunId = runId
pendingFinal = deferred
val result =
withContext(Dispatchers.IO) {
@@ -582,25 +570,11 @@ class TalkModeManager(
}
}
if (!result && pendingRunId == runId) {
clearPendingRun(runId)
}
return result
}
private fun armPendingRun(runId: String): CompletableDeferred<Boolean> {
pendingFinal?.cancel()
val deferred = CompletableDeferred<Boolean>()
pendingRunId = runId
pendingFinal = deferred
return deferred
}
private fun clearPendingRun(runId: String) {
if (pendingRunId == runId) {
if (!result) {
pendingFinal = null
pendingRunId = null
}
return result
}
private fun cacheRunCompletion(runId: String, isFinal: Boolean) {
@@ -619,12 +593,6 @@ class TalkModeManager(
}
}
private fun hasRunCompletion(runId: String): Boolean {
synchronized(completedRunsLock) {
return completedRunStates.containsKey(runId)
}
}
private fun consumeRunText(runId: String): String? {
synchronized(completedRunsLock) {
return completedRunTexts.remove(runId)

View File

@@ -4,6 +4,7 @@ import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -32,7 +33,7 @@ class AssistantLaunchTest {
requireNotNull(parsed)
assertEquals("app_action", parsed.source)
assertEquals("summarize my unread texts", parsed.prompt)
assertFalse(parsed.autoSend)
assertTrue(parsed.autoSend)
}
@Test

View File

@@ -2,7 +2,6 @@ package ai.openclaw.app
import android.app.Notification
import android.content.Intent
import android.content.pm.ServiceInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
@@ -31,35 +30,6 @@ class NodeForegroundServiceTest {
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
}
@Test
fun foregroundServiceTypesForVoiceMode_addsMicrophoneOnlyForTalkMode() {
assertEquals(
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.Off),
)
assertEquals(
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.ManualMic),
)
assertEquals(
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.TalkMode),
)
}
@Test
fun voiceNotificationSuffixReflectsActiveCaptureMode() {
assertEquals("", voiceNotificationSuffix(VoiceCaptureMode.Off, false, false, false, false))
assertEquals(
" · Mic: Listening",
voiceNotificationSuffix(VoiceCaptureMode.ManualMic, true, true, false, false),
)
assertEquals(
" · Talk: Speaking",
voiceNotificationSuffix(VoiceCaptureMode.TalkMode, false, false, true, true),
)
}
private fun buildNotification(service: NodeForegroundService): Notification {
val method =
NodeForegroundService::class.java.getDeclaredMethod(

View File

@@ -2,9 +2,7 @@ package ai.openclaw.app
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -24,32 +22,6 @@ class SecurePrefsTest {
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
}
@Test
fun voiceMicEnabled_ignoresOldTalkEnabledKey() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().putBoolean("talk.enabled", true).commit()
val prefs = SecurePrefs(context)
assertFalse(prefs.voiceMicEnabled.value)
assertFalse(plainPrefs.contains("voice.micEnabled"))
}
@Test
fun setVoiceMicEnabled_persistsNewKeyOnly() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().putBoolean("talk.enabled", false).commit()
val prefs = SecurePrefs(context)
prefs.setVoiceMicEnabled(true)
assertTrue(prefs.voiceMicEnabled.value)
assertTrue(plainPrefs.getBoolean("voice.micEnabled", false))
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
}
@Test
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
val context = RuntimeEnvironment.getApplication()

View File

@@ -108,7 +108,7 @@ class ConnectionManagerTest {
}
@Test
fun resolveTlsParamsForEndpoint_manualPrivateLanForcesTlsWhenToggleIsOff() {
fun resolveTlsParamsForEndpoint_manualPrivateLanCanStayCleartextWhenToggleIsOff() {
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
val params =
@@ -118,9 +118,7 @@ class ConnectionManagerTest {
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
assertNull(params)
}
@Test
@@ -148,7 +146,7 @@ class ConnectionManagerTest {
}
@Test
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsStillRequiresTls() {
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
@@ -166,9 +164,7 @@ class ConnectionManagerTest {
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
assertNull(params)
}
@Test
@@ -244,9 +240,9 @@ class ConnectionManagerTest {
}
@Test
fun isPrivateLanGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
fun isPrivateLanGatewayHost_acceptsLanHostsButRejectsTailnetHosts() {
assertTrue(isPrivateLanGatewayHost("192.168.1.20"))
assertFalse(isPrivateLanGatewayHost("gateway.local"))
assertTrue(isPrivateLanGatewayHost("gateway.local"))
assertFalse(isPrivateLanGatewayHost("100.64.0.9"))
assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net"))
}

View File

@@ -99,16 +99,33 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointRejectsPrivateLanCleartextWsUrls() {
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
assertNull(parsed)
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointRejectsMdnsCleartextWsUrls() {
fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://gateway.local:18789")
assertNull(parsed)
assertEquals(
GatewayEndpointConfig(
host = "gateway.local",
port = 18789,
tls = false,
displayUrl = "http://gateway.local:18789",
),
parsed,
)
}
@Test
@@ -146,9 +163,13 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() {
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
assertNull(parsed)
assertEquals("fe80::1%25eth0", parsed?.host)
assertEquals(18789, parsed?.port)
assertEquals(false, parsed?.tls)
assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl)
}
@Test
@@ -269,10 +290,19 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
fun parseGatewayEndpointResultAcceptsLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed.config,
)
assertNull(parsed.error)
}
@Test
@@ -413,7 +443,7 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveGatewayConnectConfigRejectsPrivateLanManualCleartextEndpoint() {
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
@@ -429,7 +459,9 @@ class GatewayConfigResolverTest {
fallbackPassword = "",
)
assertNull(resolved)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
@Test

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.gateway.DeviceAuthTokenStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -50,34 +49,6 @@ class TalkModeManagerTest {
assertEquals(12L, playbackGeneration(manager).get())
}
@Test
fun duplicateFinalForPendingTalkRunDoesNotStartAllResponseTts() {
val manager = createManager()
val final = CompletableDeferred<Boolean>()
manager.ttsOnAllResponses = true
setPrivateField(manager, "pendingRunId", "run-talk")
setPrivateField(manager, "pendingFinal", final)
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once"))
assertTrue(final.isCompleted)
assertEquals(0L, playbackGeneration(manager).get())
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once"))
assertEquals(0L, playbackGeneration(manager).get())
}
@Test
fun nonPendingFinalStillUsesAllResponseTts() {
val manager = createManager()
manager.ttsOnAllResponses = true
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-other", text = "speak this"))
assertEquals(1L, playbackGeneration(manager).get())
}
private fun createManager(): TalkModeManager {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
@@ -115,22 +86,6 @@ class TalkModeManagerTest {
field.isAccessible = true
return field.get(target)
}
private fun chatFinalPayload(runId: String, text: String): String {
return """
{
"runId": "$runId",
"sessionKey": "main",
"state": "final",
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "$text" }
]
}
}
""".trimIndent()
}
}
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {

View File

@@ -1,17 +1,5 @@
# OpenClaw iOS Changelog
## 2026.4.26 - 2026-04-26
Maintenance update for the current OpenClaw development release.
## 2026.4.25 - 2026-04-25
Maintenance update for the current OpenClaw development release.
## 2026.4.23 - 2026-04-23
Maintenance update for the current OpenClaw development release.
## 2026.4.22 - 2026-04-22
Maintenance update for the current OpenClaw development 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.26
OPENCLAW_MARKETING_VERSION = 2026.4.26
OPENCLAW_IOS_VERSION = 2026.4.22
OPENCLAW_MARKETING_VERSION = 2026.4.22
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -21,7 +21,6 @@ struct SettingsTab: View {
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@@ -279,11 +278,6 @@ struct SettingsTab: View {
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
self.appModel.setTalkEnabled(newValue)
}
Picker("Speech Language", selection: self.$talkSpeechLocale) {
ForEach(TalkSpeechLocale.supportedOptions()) { option in
Text(option.label).tag(option.id)
}
}
self.featureToggle(
"Background Listening",
isOn: self.$talkBackgroundEnabled,

View File

@@ -12,7 +12,6 @@ struct TalkModeGatewayConfigState {
let rawConfigApiKey: String?
let interruptOnSpeech: Bool?
let silenceTimeoutMs: Int
let speechLocaleID: String?
}
enum TalkModeGatewayConfigParser {
@@ -54,7 +53,6 @@ enum TalkModeGatewayConfigParser {
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
let speechLocaleID = TalkConfigParsing.resolvedSpeechLocaleID(talk)
return TalkModeGatewayConfigState(
activeProvider: activeProvider,
@@ -66,7 +64,6 @@ enum TalkModeGatewayConfigParser {
defaultOutputFormat: defaultOutputFormat,
rawConfigApiKey: rawConfigApiKey,
interruptOnSpeech: interruptOnSpeech,
silenceTimeoutMs: silenceTimeoutMs,
speechLocaleID: speechLocaleID)
silenceTimeoutMs: silenceTimeoutMs)
}
}

View File

@@ -87,7 +87,6 @@ final class TalkModeManager: NSObject {
private var apiKey: String?
private var voiceAliases: [String: String] = [:]
private var interruptOnSpeech: Bool = true
private var gatewaySpeechLocaleID: String?
private var mainSessionKey: String = "main"
private var fallbackVoiceId: String?
private var lastPlaybackWasPCM: Bool = false
@@ -501,17 +500,12 @@ final class TalkModeManager: NSObject {
#endif
self.stopRecognition()
let localSpeechLocale = UserDefaults.standard.string(forKey: TalkSpeechLocale.storageKey)
let resolvedSpeech = TalkSpeechLocale.makeRecognizer(
localSelection: localSpeechLocale,
gatewaySelection: self.gatewaySpeechLocaleID)
self.speechRecognizer = resolvedSpeech.recognizer
self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else {
throw NSError(domain: "TalkMode", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Speech recognizer unavailable",
])
}
GatewayDiagnostics.log("talk speech: locale=\(resolvedSpeech.localeID ?? "default")")
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
self.recognitionRequest?.shouldReportPartialResults = true
@@ -2033,7 +2027,6 @@ extension TalkModeManager {
if let interrupt = parsed.interruptOnSpeech {
self.interruptOnSpeech = interrupt
}
self.gatewaySpeechLocaleID = parsed.speechLocaleID
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
GatewayDiagnostics.log(
@@ -2048,7 +2041,6 @@ extension TalkModeManager {
self.gatewayTalkDefaultModelId = nil
self.gatewayTalkApiKeyConfigured = false
self.gatewayTalkConfigLoaded = false
self.gatewaySpeechLocaleID = nil
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
}
}

View File

@@ -1,100 +0,0 @@
import Foundation
import OpenClawKit
import Speech
enum TalkSpeechLocale {
static let storageKey = "talk.speechLocale"
static let automaticID = "auto"
static let fallbackLocaleID = "en-US"
struct Option: Identifiable {
let id: String
let label: String
}
static func supportedOptions(
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
) -> [Option] {
var seen = Set<String>()
let dynamic: [Option] = supportedLocales
.compactMap { locale in
let id = self.canonicalID(locale.identifier)
guard seen.insert(id).inserted else { return nil }
return Option(id: id, label: self.friendlyName(for: locale))
}
.sorted { (lhs: Option, rhs: Option) in
lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending
}
return [Option(id: self.automaticID, label: "Automatic")] + dynamic
}
static func resolvedLocaleID(
localSelection: String?,
gatewaySelection: String?,
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
fallbackLocaleID: String = Self.fallbackLocaleID,
supportedLocaleIDs: Set<String>
) -> String? {
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
preferredLocaleIDs: [
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
TalkConfigParsing.normalizedExplicitSpeechLocaleID(gatewaySelection),
deviceLocaleID,
],
fallbackLocaleID: fallbackLocaleID,
supportedLocaleIDs: supportedLocaleIDs)
}
static func makeRecognizer(
localSelection: String?,
gatewaySelection: String?,
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) {
let supportedIDs = Set(supportedLocales.map(\.identifier))
guard let localeID = self.resolvedLocaleID(
localSelection: localSelection,
gatewaySelection: gatewaySelection,
supportedLocaleIDs: supportedIDs)
else {
let recognizer = SFSpeechRecognizer()
return (recognizer, recognizer?.locale.identifier)
}
if let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeID)) {
return (recognizer, localeID)
}
let recognizer = SFSpeechRecognizer()
return (recognizer, recognizer?.locale.identifier)
}
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
}
private static func normalizedLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedSpeechLocaleID(raw)
}
private static func canonicalID(_ raw: String) -> String {
raw.replacingOccurrences(of: "_", with: "-")
}
private static func friendlyName(for locale: Locale) -> String {
let id = self.canonicalID(locale.identifier)
let cleanLocale = Locale(identifier: id)
if let langCode = cleanLocale.language.languageCode?.identifier,
let lang = cleanLocale.localizedString(forLanguageCode: langCode),
let regionCode = cleanLocale.region?.identifier,
let region = cleanLocale.localizedString(forRegionCode: regionCode)
{
return "\(lang) (\(region))"
}
if let langCode = cleanLocale.language.languageCode?.identifier,
let lang = cleanLocale.localizedString(forLanguageCode: langCode)
{
return lang
}
return cleanLocale.localizedString(forIdentifier: id) ?? id
}
}

View File

@@ -47,16 +47,6 @@ private let iOSSilenceTimeoutMs = 900
fallback: iOSSilenceTimeoutMs) == 1500)
}
@Test func readsConfiguredSpeechLocale() {
let talk: [String: Any] = [
"speechLocale": " ru-RU ",
]
#expect(
TalkConfigParsing.resolvedSpeechLocaleID(
TalkConfigParsing.bridgeFoundationDictionary(talk)) == "ru-RU")
}
@Test func defaultsSilenceTimeoutMsWhenMissing() {
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}

View File

@@ -1,41 +0,0 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct TalkSpeechLocaleTests {
@Test func localSelectionOverridesGatewayConfig() {
let locale = TalkSpeechLocale.resolvedLocaleID(
localSelection: "de-DE",
gatewaySelection: "ru-RU",
deviceLocaleID: "en-US",
supportedLocaleIDs: ["de-DE", "ru-RU", "en-US"])
#expect(locale == "de-DE")
}
@Test func automaticLocalSelectionAllowsGatewayConfig() {
let locale = TalkSpeechLocale.resolvedLocaleID(
localSelection: TalkSpeechLocale.automaticID,
gatewaySelection: "ru_RU",
deviceLocaleID: "en-US",
supportedLocaleIDs: ["ru-RU", "en-US"])
#expect(locale == "ru-RU")
}
@Test func unsupportedConfiguredLocaleFallsBackToDeviceThenEnglish() {
let deviceLocale = TalkSpeechLocale.resolvedLocaleID(
localSelection: "zz-ZZ",
gatewaySelection: nil,
deviceLocaleID: "fr-FR",
supportedLocaleIDs: ["fr-FR", "en-US"])
let english = TalkSpeechLocale.resolvedLocaleID(
localSelection: "zz-ZZ",
gatewaySelection: nil,
deviceLocaleID: "yy-YY",
supportedLocaleIDs: ["en-US"])
#expect(deviceLocale == "fr-FR")
#expect(english == "en-US")
}
}

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.26"
"version": "2026.4.22"
}

View File

@@ -1,7 +1,6 @@
import AppKit
import Foundation
import Observation
import OpenClawKit
import ServiceManagement
import SwiftUI
@@ -177,23 +176,6 @@ final class AppState {
}
}
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
}
}
}
var talkShiftToStopEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
}
}
}
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
var seamColorHex: String?
@@ -327,18 +309,6 @@ final class AppState {
self.voiceWakeTriggersTalkMode = UserDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkPhaseSoundsEnabled = storedPhaseSounds
} else {
self.talkPhaseSoundsEnabled = true
UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
}
if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
self.talkShiftToStopEnabled = storedShiftToStop
} else {
self.talkShiftToStopEnabled = true
UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
}
self.seamColorHex = nil
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
@@ -367,8 +337,7 @@ final class AppState {
if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl),
!LoopbackHost.isLoopbackHost(host)
let host = AppState.remoteHost(from: configRemoteUrl)
{
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
@@ -437,30 +406,6 @@ final class AppState {
return trimmed
}
private static func sshTunnelGatewayUrl(existingUrl: String?, expectedRemoteHost: String?) -> String {
let fallback = "ws://127.0.0.1:18789"
let trimmed = existingUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty,
let url = URL(string: trimmed),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return fallback
}
let preservePort: Bool = if LoopbackHost.isLoopbackHost(host) {
true
} else if let expectedRemoteHost {
OpenClawConfigFile.canonicalHostForComparison(host) ==
OpenClawConfigFile.canonicalHostForComparison(expectedRemoteHost)
} else {
false
}
guard preservePort else { return fallback }
return "ws://127.0.0.1:\(url.port ?? 18789)"
}
private static func updateGatewayString(
_ dictionary: inout [String: Any],
key: String,
@@ -517,14 +462,17 @@ final class AppState {
case .ssh:
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
if let host = draft.remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
}
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let desiredUrl = Self.sshTunnelGatewayUrl(
existingUrl: existingUrl,
expectedRemoteHost: expectedRemoteHost)
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: draft.remoteIdentity) || changed
}
@@ -592,8 +540,7 @@ final class AppState {
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl),
!LoopbackHost.isLoopbackHost(host)
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
}
@@ -831,8 +778,6 @@ extension AppState {
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false
state.talkEnabled = false
state.talkPhaseSoundsEnabled = true
state.talkShiftToStopEnabled = true
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local

View File

@@ -1,24 +1,6 @@
import Foundation
import OpenClawProtocol
func whatsappLoginWaitRequestTimeoutMs(
startedAt: Date,
timeoutMs: Int,
didRunFinalWait: inout Bool,
now: Date = Date()) -> Int?
{
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
let remainingMs = max(timeoutMs - elapsedMs, 0)
if remainingMs > 0 {
return remainingMs
}
if didRunFinalWait {
return nil
}
didRunFinalWait = true
return 1
}
extension ChannelsStore {
func start() {
guard !self.isPreview else { return }
@@ -95,28 +77,18 @@ extension ChannelsStore {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
let startedAt = Date()
var didRunFinalWait = false
do {
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: timeoutMs,
didRunFinalWait: &didRunFinalWait)
{
var params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(remainingMs),
]
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
}
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(remainingMs) + 5000)
self.applyWhatsAppLoginWaitResult(result)
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
break
}
let params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(timeoutMs),
]
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(timeoutMs) + 5000)
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if result.connected {
self.whatsappLoginQrDataUrl = nil
}
} catch {
self.whatsappLoginMessage = error.localizedDescription
@@ -179,10 +151,9 @@ private struct WhatsAppLoginStartResult: Codable {
let connected: Bool?
}
struct WhatsAppLoginWaitResult: Codable {
private struct WhatsAppLoginWaitResult: Codable {
let connected: Bool
let message: String
let qrDataUrl: String?
}
private struct ChannelLogoutResult: Codable {

View File

@@ -290,16 +290,6 @@ final class ChannelsStore {
return self.snapshot?.channelOrder ?? []
}
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if let qrDataUrl = result.qrDataUrl {
self.whatsappLoginQrDataUrl = qrDataUrl
} else if result.connected {
self.whatsappLoginQrDataUrl = nil
}
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}

View File

@@ -24,8 +24,6 @@ let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled"
let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode"
let talkEnabledKey = "openclaw.talkEnabled"
let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled"
let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled"
let iconOverrideKey = "openclaw.iconOverride"
let connectionModeKey = "openclaw.connectionMode"
let remoteTargetKey = "openclaw.remoteTarget"

View File

@@ -9,15 +9,8 @@ enum ExecAllowlistMatcher {
for entry in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case let .valid(pattern):
if ExecApprovalHelpers.patternHasPathSelector(pattern) {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if pattern != "*",
!ExecApprovalHelpers.patternHasPathSelector(rawExecutable),
self.matchesExecutableBasename(pattern: pattern, resolution: resolution)
{
return entry
}
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
case .invalid:
continue
}
@@ -41,20 +34,6 @@ enum ExecAllowlistMatcher {
return matches
}
private static func matchesExecutableBasename(
pattern: String,
resolution: ExecCommandResolution) -> Bool
{
var candidates = Set<String>()
if !resolution.executableName.isEmpty {
candidates.insert(resolution.executableName)
}
if let resolvedPath = resolution.resolvedPath, !resolvedPath.isEmpty {
candidates.insert(URL(fileURLWithPath: resolvedPath).lastPathComponent)
}
return candidates.contains { self.matches(pattern: pattern, target: $0) }
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }

View File

@@ -616,18 +616,6 @@ enum ExecApprovalsStore {
let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
if !ExecApprovalHelpers.patternHasPathSelector(trimmedPattern),
!trimmedResolved.isEmpty,
case let .valid(migratedPattern) = ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved)
{
return ExecAllowlistEntry(
id: entry.id,
pattern: migratedPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
}
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case let .valid(pattern):
return ExecAllowlistEntry(
@@ -736,10 +724,11 @@ enum ExecApprovalHelpers {
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return .invalid(.empty) }
guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) }
return .valid(trimmed)
}
static func isValidAllowlistPattern(_ pattern: String?) -> Bool {
static func isPathPattern(_ pattern: String?) -> Bool {
switch self.validateAllowlistPattern(pattern) {
case .valid:
true
@@ -748,11 +737,6 @@ enum ExecApprovalHelpers {
}
}
static func isPathPattern(_ pattern: String?) -> Bool {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return self.patternHasPathSelector(trimmed)
}
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
@@ -775,7 +759,7 @@ enum ExecApprovalHelpers {
return pattern.isEmpty ? nil : pattern
}
static func patternHasPathSelector(_ pattern: String) -> Bool {
private static func containsPathComponent(_ pattern: String) -> Bool {
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
}
}

View File

@@ -42,7 +42,6 @@ struct GatewayAgentInvocation {
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
var voiceWakeTrigger: String?
}
/// Single, shared Gateway websocket connection for the whole app.
@@ -71,7 +70,6 @@ actor GatewayConnection {
case wizardStatus = "wizard.status"
case talkConfig = "talk.config"
case talkMode = "talk.mode"
case talkSpeak = "talk.speak"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case channelsLogout = "channels.logout"
@@ -500,10 +498,6 @@ extension GatewayConnection {
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
if let trigger = invocation.voiceWakeTrigger {
params["voiceWakeTrigger"] = AnyCodable(
trigger.trimmingCharacters(in: .whitespacesAndNewlines))
}
do {
try await self.requestVoid(method: .agent, params: params)

View File

@@ -1,11 +1,7 @@
import Foundation
import OpenClawDiscovery
import OpenClawKit
@MainActor
enum GatewayDiscoverySelectionSupport {
private static let defaultSshTunnelGatewayUrl = "ws://127.0.0.1:18789"
static func applyRemoteSelection(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
@@ -17,40 +13,18 @@ enum GatewayDiscoverySelectionSupport {
state.remoteTransport = preferredTransport
}
if preferredTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteUrl = self.sshTunnelGatewayUrl(current: state.remoteUrl)
}
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if preferredTransport == .direct {
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
private static func sshTunnelGatewayUrl(current: String) -> String {
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let url = URL(string: trimmed),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty,
LoopbackHost.isLoopbackHost(host)
else {
return self.defaultSshTunnelGatewayUrl
}
return "ws://127.0.0.1:\(url.port ?? 18789)"
}
static func preferredTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
current: AppState.RemoteTransport) -> AppState.RemoteTransport

View File

@@ -135,10 +135,6 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
self.osLogger = os.Logger(subsystem: subsystem, category: category)
}
func log(event: LogEvent) {
self.writeLog(level: event.level, message: event.message, metadata: event.metadata)
}
func log(
level: Logger.Level,
message: Logger.Message,
@@ -147,14 +143,6 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
file: String,
function: String,
line: UInt)
{
self.writeLog(level: level, message: message, metadata: metadata)
}
private func writeLog(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?)
{
let merged = Self.mergeMetadata(self.metadata, metadata)
let rendered = Self.renderMessage(message, metadata: merged)
@@ -198,17 +186,6 @@ struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
let label: String
var metadata: Logger.Metadata = [:]
func log(event: LogEvent) {
self.writeLog(
level: event.level,
message: event.message,
metadata: event.metadata,
source: event.source,
file: event.file,
function: event.function,
line: event.line)
}
func log(
level: Logger.Level,
message: Logger.Message,
@@ -217,25 +194,6 @@ struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
file: String,
function: String,
line: UInt)
{
self.writeLog(
level: level,
message: message,
metadata: metadata,
source: source,
file: file,
function: function,
line: line)
}
private func writeLog(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt)
{
guard AppLogSettings.fileLoggingEnabled() else { return }
let (subsystem, category) = OpenClawLogging.parseLabel(self.label)

View File

@@ -54,15 +54,8 @@ actor MacNodeBrowserProxy {
func request(paramsJSON: String?) async throws -> String {
let params = try Self.decodeRequestParams(from: paramsJSON)
let endpoint = self.endpointProvider()
let request = try Self.makeRequest(params: params, endpoint: endpoint)
let data: Data
let response: URLResponse
do {
(data, response) = try await self.performRequest(request)
} catch {
throw Self.unavailableError(endpoint: endpoint, cause: error)
}
let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider())
let (data, response) = try await self.performRequest(request)
let http = try Self.requireHTTPResponse(response)
guard (200..<300).contains(http.statusCode) else {
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
@@ -172,19 +165,6 @@ actor MacNodeBrowserProxy {
return http
}
private static func unavailableError(endpoint: Endpoint, cause: Error) -> NSError {
let url = endpoint.baseURL.absoluteString
let message = """
UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \
In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \
on this Mac and target that browser node. Underlying error: \(cause.localizedDescription)
"""
return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [
NSLocalizedDescriptionKey: message,
NSUnderlyingErrorKey: cause,
])
}
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
let error = object["error"] as? String,

View File

@@ -116,40 +116,27 @@ final class MacNodeModeCoordinator {
}
}
nonisolated static func resolvedCaps(
browserControlEnabled: Bool,
cameraEnabled: Bool,
locationMode: OpenClawLocationMode,
connectionMode: AppState.ConnectionMode) -> [String]
{
private func currentCaps() -> [String] {
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
if browserControlEnabled, connectionMode == .local {
if OpenClawConfigFile.browserControlEnabled() {
caps.append(OpenClawCapability.browser.rawValue)
}
if cameraEnabled {
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(OpenClawCapability.camera.rawValue)
}
if locationMode != .off {
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
if OpenClawLocationMode(rawValue: rawLocationMode) != .off {
caps.append(OpenClawCapability.location.rawValue)
}
return caps
}
private func currentCaps() -> [String] {
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return Self.resolvedCaps(
browserControlEnabled: OpenClawConfigFile.browserControlEnabled(),
cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false,
locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off,
connectionMode: AppStateStore.shared.connectionMode)
}
private func currentPermissions() async -> [String: Bool] {
let statuses = await PermissionManager.status()
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
}
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
private func currentCommands(caps: [String]) -> [String] {
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
@@ -184,10 +171,6 @@ final class MacNodeModeCoordinator {
return commands
}
private func currentCommands(caps: [String]) -> [String] {
Self.resolvedCommands(caps: caps)
}
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
guard url.scheme?.lowercased() == "wss" else { return nil }
let host = url.host ?? "gateway"

View File

@@ -192,17 +192,20 @@ enum OpenClawConfigFile {
}
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
guard let normalizedSshHost = canonicalHostForComparison(sshHost),
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSshHost.isEmpty,
let url = self.remoteGatewayUrl(),
let port = url.port,
port > 0,
let urlHost = url.host,
let normalizedUrlHost = canonicalHostForComparison(urlHost)
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!urlHost.isEmpty
else {
return nil
}
guard normalizedSshHost == normalizedUrlHost else { return nil }
let sshKey = Self.hostKey(trimmedSshHost)
let urlKey = Self.hostKey(urlHost)
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
return port
}
@@ -220,16 +223,6 @@ enum OpenClawConfigFile {
}
}
static func setRemoteGatewayUrlString(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.updateGatewayDict { gateway in
var remote = gateway["remote"] as? [String: Any] ?? [:]
remote["url"] = trimmed
gateway["remote"] = remote
}
}
static func clearRemoteGatewayUrl() {
self.updateGatewayDict { gateway in
guard var remote = gateway["remote"] as? [String: Any] else { return }
@@ -256,17 +249,15 @@ enum OpenClawConfigFile {
return url
}
static func canonicalHostForComparison(_ raw: String?) -> String? {
guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!host.isEmpty
else {
return nil
static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
let digits = CharacterSet(charactersIn: "0123456789.")
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
return trimmed
}
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
while host.hasSuffix(".") {
host.removeLast()
}
return host.isEmpty ? nil : host
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func parseConfigData(_ data: Data) -> [String: Any]? {

View File

@@ -150,11 +150,9 @@ final class RemotePortTunnel {
else {
return nil
}
guard let sshKey = OpenClawConfigFile.canonicalHostForComparison(sshHost),
let urlKey = OpenClawConfigFile.canonicalHostForComparison(host)
else {
return nil
}
let sshKey = OpenClawConfigFile.hostKey(sshHost)
let urlKey = OpenClawConfigFile.hostKey(host)
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
guard sshKey == urlKey else {
Self.logger.debug(
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.26</string>
<string>2026.4.22</string>
<key>CFBundleVersion</key>
<string>2026042600</string>
<string>2026042200</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

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