Compare commits

..

1 Commits

Author SHA1 Message Date
Mason Huang
63803aa319 CI: split CodeQL JavaScript lanes 2026-04-26 10:46:43 +08:00
1971 changed files with 33444 additions and 99389 deletions

View File

@@ -16,19 +16,6 @@ warm caches, local build state, and fast feedback.
Testbox is the expensive path. Reach for it deliberately.
OpenClaw maintainers can opt into Testbox-first validation by setting
`OPENCLAW_TESTBOX=1` in their environment or standing agent rules. This mode is
maintainers-only and requires Blacksmith access.
When `OPENCLAW_TESTBOX=1` is set in OpenClaw:
- Pre-warm a Testbox early for longer, wider, or uncertain work.
- Prefer Testbox for `pnpm` gates, e2e, package-like proof, and broad suites.
- Reuse the same Testbox ID for every run command in the same task/session.
- Use local commands only when the task explicitly sets
`OPENCLAW_LOCAL_CHECK_MODE=throttled|full`, or when the user asks for local
proof.
## Install the CLI
If `blacksmith` is not installed, install it:
@@ -94,8 +81,7 @@ Prefer Testbox when:
- you are reproducing CI-only failures
- you need the exact workflow image/job environment from GitHub Actions
For OpenClaw specifically, normal local iteration stays local unless maintainer
Testbox mode is enabled with `OPENCLAW_TESTBOX=1`:
For OpenClaw specifically, normal local iteration should stay local:
- `pnpm check:changed`
- `pnpm test:changed`
@@ -103,49 +89,27 @@ Testbox mode is enabled with `OPENCLAW_TESTBOX=1`:
- `pnpm test:serial`
- `pnpm build`
If `OPENCLAW_TESTBOX=1` is enabled, run those same repo commands inside the
warm Testbox. If the user wants laptop-friendly local proof for one command, use
the explicit escape hatch `OPENCLAW_LOCAL_CHECK_MODE=throttled`.
For installable-package product proof, prefer the GitHub `Package Acceptance`
workflow over an ad hoc Testbox command. It resolves one package candidate
(`source=npm`, `source=ref`, `source=url`, or `source=artifact`), uploads it as
`package-under-test`, and runs the reusable Docker E2E lanes against that exact
tarball on GitHub/Blacksmith runners. Use `workflow_ref` for the trusted
workflow/harness code and `package_ref` for the source ref to pack when testing
an older trusted branch, tag, or SHA.
Only use Testbox in OpenClaw when the user explicitly wants CI-parity or the
check truly depends on remote secrets/services that the local repo loop cannot
provide.
## Setup: Warmup before coding
If you decided Testbox is warranted, warm one up early. This returns an ID
instantly and boots the CI environment in the background while you work:
If you decided Testbox is actually warranted, warm one up early. This returns
an ID instantly and boots the CI environment in the background while you work:
blacksmith testbox warmup ci-check-testbox.yml
# → tbx_01jkz5b3t9...
Save this ID. You need it for every `run` command.
For OpenClaw maintainer Testbox mode, pre-warm at the start of longer or wider
tasks:
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
Use the build-artifact warmup when e2e/package/build proof benefits from seeded
`dist/`, `dist-runtime/`, and build-all caches:
blacksmith testbox warmup ci-build-artifacts-testbox.yml --ref main --idle-timeout 90
Warmup dispatches a GitHub Actions workflow that provisions a VM with the
full CI environment: dependencies installed, services started, secrets
injected, and a clean checkout of the repo at the default branch.
In OpenClaw, raw commit SHAs are not reliable dispatch refs for `warmup --ref`;
use a branch or tag. The build-artifact workflow resolves `openclaw@beta` and
`openclaw@latest` to SHA cache keys internally.
Options:
--ref <branch|tag> Git ref to dispatch against (default: repo's default branch)
--ref <branch> Git ref to dispatch against (default: repo's default branch)
--job <name> Specific job within the workflow (if it has multiple)
--idle-timeout <min> Idle timeout in minutes (default: 30)
@@ -262,11 +226,6 @@ services, CI-only runners, or reproducibility against the workflow image.
If the repo says local tests/builds are the normal path, follow the repo.
OpenClaw maintainer exception: if `OPENCLAW_TESTBOX=1` is set by the user or
agent environment, treat Testbox as the normal validation path for this repo.
Use `OPENCLAW_LOCAL_CHECK_MODE=throttled|full` as the explicit local escape
hatch.
## When to use
Use Testbox when:
@@ -283,13 +242,12 @@ checks that need parity or remote state.
## Workflow
1. Decide whether the repo's local loop is the right default. For OpenClaw,
`OPENCLAW_TESTBOX=1` makes Testbox the maintainer default.
2. If Testbox is warranted, warm up early:
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90` → save the ID
1. Decide whether the repo's local loop is the right default.
2. Only if Testbox is warranted, warm up early:
`blacksmith testbox warmup ci-check-testbox.yml` → save the ID
3. Write code while the testbox boots in the background.
4. Run the remote command when needed:
`blacksmith testbox run --id <ID> "pnpm check:changed"`
`blacksmith testbox run --id <ID> "npm test"`
5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
@@ -310,9 +268,9 @@ Observed full-suite time on Blacksmith Testbox is about 3-4 minutes:
- 173-180s on a warmed box
- 219s on a fresh 32-vCPU box
When validating before commit/push in maintainer Testbox mode, run
`pnpm check:changed` inside the warmed box first when appropriate, then the full
suite with the profile above if broad confidence is needed.
When validating before commit/push, run `pnpm check:changed` first when
appropriate, then the full suite with the profile above if broad confidence is
needed.
## Examples
@@ -366,14 +324,12 @@ timeout is reached). Default timeout is 5m; use `--wait-timeout` for longer
blacksmith testbox stop --id <ID>
Testboxes automatically shut down after being idle (default: 30 minutes).
If you need a longer session, increase the timeout at warmup time. For OpenClaw
maintainer work, use 90 minutes for long-running sessions:
If you need a longer session, increase the timeout at warmup time:
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 90
blacksmith testbox warmup ci-build-artifacts-testbox.yml --idle-timeout 90
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 60
## With options
blacksmith testbox warmup ci-check-testbox.yml --ref main
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 90
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 60
blacksmith testbox run --id <ID> "go test ./..."

View File

@@ -7,22 +7,6 @@ description: Review, triage, close, label, comment on, or land OpenClaw PRs/issu
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.
@@ -75,9 +59,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:

View File

@@ -62,24 +62,6 @@ 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.
## Matrix live profiles
`pnpm openclaw qa matrix` defaults to the full `all` profile. Use explicit
profiles for faster CI/release proof:
```bash
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000 \
pnpm openclaw qa matrix --profile fast --fail-fast
```
- `fast`: release-critical transport contract, excluding generated image and
deep E2EE recovery inventory.
- `transport`, `media`, `e2ee-smoke`, `e2ee-deep`, `e2ee-cli`: sharded full
Matrix coverage.
- `QA-Lab - All Lanes` uses explicit `fast` Matrix on scheduled runs. Manual
dispatch keeps `matrix_profile=all` as the default and always shards that full
Matrix selection.
## QA credentials and 1Password
- Use `op` only inside `tmux` for QA secret lookup in this repo.

View File

@@ -25,36 +25,15 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Before release branching, commit any dirty files in coherent groups, push,
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
changelog rewrite immediately before creating the release branch.
- During release planning, inspect both `src/plugins/compat/registry.ts` and
`src/commands/doctor/shared/deprecation-compat.ts` before branching and again
before final publish. For every deprecated or removal-pending compatibility
record whose `removeAfter` date is on or before the release date, either
remove the compatibility path where safe and validate the affected tests, or
write down why removal is blocked and get explicit maintainer approval before
shipping the expired compatibility path.
- When removing deprecated runtime/config compatibility, preserve any doctor
migration, repair, or hint that is still needed by supported upgrade paths.
Doctor-side compatibility should stay tracked in
`src/commands/doctor/shared/deprecation-compat.ts` until maintainers confirm
the repair is no longer needed.
- Revalidate compatibility replacement text during release planning. The
recommended replacement can shift as plugin ownership, externalization, and
config footprint move, so do not blindly copy stale replacement annotations
into release notes.
- 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
@@ -96,11 +75,6 @@ Use this skill for release and publish-time workflow. Keep ordinary development
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, overlap remote/manual release rosters where useful,
but avoid piling local Docker, Parallels, and QA-Lab work onto the same host
when it would create system-load noise. 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
@@ -133,13 +107,6 @@ Use this skill for release and publish-time workflow. Keep ordinary development
`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 preparing release notes, scan `src/plugins/compat/registry.ts` and
`src/commands/doctor/shared/deprecation-compat.ts` for compatibility records
with `warningStarts` or `removeAfter` within 7 days after the release date.
Add an `Upcoming deprecations` note to the release notes when any exist,
including the compatibility code, target date, replacement, and a link to the
record's `docsPath` or `/plugins/compatibility` when no more specific
deprecation page exists.
- 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`
@@ -325,11 +292,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- 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`, and require success. This workflow is
maintainer-dispatched and intentionally has no `npm-release` approval gate;
`qa-live-shared` only supplies the shared QA secrets. This is the default
button path for installed-package onboarding, Telegram setup, and real
Telegram E2E against the published npm package.
`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.
@@ -526,10 +491,8 @@ 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 local build, Docker, and Parallels parts of 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.
@@ -566,16 +529,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
22. 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. Once the beta is live, start remote/manual rosters where they
can overlap safely, but keep local Docker and Parallels load controlled.
Ensure the full expensive roster has passed at least once before
stable/latest promotion. The roster includes the manual Actions >
23. 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. 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

View File

@@ -1,488 +0,0 @@
---
name: openclaw-testing
description: Choose, run, rerun, or debug OpenClaw tests, CI checks, Docker E2E lanes, release validation, and the cheapest safe verification path.
---
# OpenClaw Testing
Use this skill when deciding what to test, debugging failures, rerunning CI,
or validating a change without wasting hours.
## Read First
- `docs/reference/test.md` for local test commands.
- `docs/ci.md` for CI scope, release checks, Docker chunks, and runner behavior.
- Scoped `AGENTS.md` files before editing code under a subtree.
## Default Rule
Prove the touched surface first. Do not reflexively run the whole suite.
1. Inspect the diff and classify the touched surface:
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
- tests only: `pnpm test:changed`
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
2. Reproduce narrowly before fixing.
3. Fix root cause.
4. Rerun the same narrow proof.
5. Broaden only when the touched contract demands it.
## Guardrails
- Do not kill unrelated processes or tests. If something is running elsewhere, treat it as owned by the user or another agent.
- Do not run expensive local Docker, full release checks, full `pnpm test`, or full `pnpm check` unless the user asks or the change genuinely requires it.
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
## Local Test Shortcuts
```bash
pnpm changed:lanes --json
pnpm check:changed # changed typecheck/lint/guards; no Vitest
pnpm test:changed # cheap smart changed Vitest targets
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
pnpm test <path-or-filter> -- --reporter=verbose
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
```
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
`pnpm test` wrapper so project routing, workers, and setup stay correct.
## Command Semantics
- `pnpm check` and `pnpm check:changed` do not run Vitest tests. They are for
typecheck, lint, and guard proof.
- `pnpm test` and `pnpm test:changed` run Vitest tests.
- `pnpm test:changed` is intentionally cheap by default: direct test edits,
sibling tests, explicit source mappings, and import-graph dependents.
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` is the explicit broad
fallback for harness/config/package edits that genuinely need it.
- Do not run extension sweeps just because core changed. If a core edit is for a
specific plugin bug, run that plugin's tests explicitly. If a public SDK or
contract change needs consumer proof, choose the smallest representative
plugin/contract tests first, then broaden only when the risk justifies it.
- The test wrapper prints a short `[test] passed|failed|skipped ... in ...`
line. Vitest's own duration is still the per-shard detail.
## Routing Model
- `pnpm changed:lanes --json` answers "which check lanes does this diff touch?"
It is used by `pnpm check:changed` for typecheck/lint/guard selection.
- `pnpm test:changed` answers "which Vitest targets are worth running now?" It
uses the same changed path list, but applies a cheaper test-target resolver.
- Direct test edits run themselves. Source edits prefer explicit mappings,
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
edits are skipped by default unless they have precise mapped tests.
- Public SDK or contract edits do not automatically run every plugin test.
`check:changed` proves extension type contracts; the agent chooses the
smallest plugin/contract Vitest proof that matches the actual risk.
- Use `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` only when a harness,
config, package, or unknown-root edit really needs the broad Vitest fallback.
## CI Debugging
Start with current run state, not logs for everything:
```bash
gh run list --branch main --limit 10
gh run view <run-id> --json status,conclusion,headSha,url,jobs
gh run view <run-id> --job <job-id> --log
```
- Check exact SHA. Ignore newer unrelated `main` unless asked.
- For cancelled same-branch runs, confirm whether a newer run superseded it.
- Fetch full logs only for failed or relevant jobs.
## GitHub Release Workflows
Use the smallest workflow that proves the current risk. The full umbrella is
available, but it is usually the last step after narrower proof, not the first
rerun after a focused patch.
### Full Release Validation
`Full Release Validation` (`.github/workflows/full-release-validation.yml`) is
the manual "everything before release" umbrella. It resolves a target ref, then
dispatches:
- manual `CI` for the full normal CI graph
- `OpenClaw Release Checks` for install smoke, cross-OS release checks, live and
E2E checks, Docker release-path suites, OpenWebUI, QA Lab, fast Matrix, and
Telegram release lanes
- optional post-publish Telegram E2E when a package spec is supplied
Run it only when validating an actual release candidate, after broad shared CI
or release orchestration changes, or when explicitly asked:
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<branch-or-sha> \
-f workflow_ref=main \
-f provider=openai \
-f mode=both
```
If a full run is already active on a newer `origin/main`, prefer watching that
run over dispatching a duplicate. If you accidentally dispatch a stale duplicate,
cancel it and monitor the current run.
### Release Evidence
After release-candidate validation or before a release decision, record the
important run ids in the private `openclaw/releases-private` evidence ledger.
Use the manual `OpenClaw Release Evidence`
(`openclaw-release-evidence.yml`) workflow there. It writes durable summaries
under `evidence/<release-id>/` and commits:
- `release-evidence.md`
- `release-evidence.json`
- `index.json`
- `runs/<label>.json`
Use one run per line:
```text
full-release-validation openclaw/openclaw <run-id> blocking
package-acceptance openclaw/openclaw <run-id> blocking
release-checks openclaw/openclaw <run-id> blocking
```
Store summaries, run URLs, artifact metadata, timings, pass/fail state, and
short release-manager notes there. Do not store raw logs, provider
prompts/responses, channel transcripts, signing material, or secret-bearing
config in git; raw logs stay in Actions artifacts.
When `Full Release Validation` completes and
`OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN` is configured in the public repo, it
requests the private `OpenClaw Release Evidence From Full Validation` workflow.
That private workflow reads the parent full-validation run, extracts the child
CI/release-checks/Telegram run ids from the parent logs, and opens the evidence
PR automatically. If the token is absent or the run predates this wiring, trigger
that private workflow manually with the full-validation run id.
### Release Checks
`OpenClaw Release Checks` (`openclaw-release-checks.yml`) is the release child
workflow. It is broader than normal CI but narrower than the umbrella because it
does not dispatch the separate full normal CI child. It runs Package Acceptance
with `telegram_mode=mock-openai`, so the release package tarball also goes
through Telegram package QA. Use it when release-path validation is needed
without rerunning the entire umbrella.
```bash
gh workflow run openclaw-release-checks.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both
```
### QA Lab Matrix Profiles
`pnpm openclaw qa matrix` defaults to `--profile all`. Do not assume the CLI
default is the fast release path. Use explicit profiles:
- `--profile fast --fail-fast`: release-critical Matrix transport contract
- `--profile transport|media|e2ee-smoke|e2ee-deep|e2ee-cli`: sharded full
Matrix proof
- `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`: CI-friendly no-reply quiet
window when paired with fast or sharded gates
`QA-Lab - All Lanes` uses explicit fast Matrix on scheduled runs; manual
dispatch keeps `matrix_profile=all` as the default and always shards that full
Matrix selection. `OpenClaw Release Checks` uses explicit fast Matrix; run the
all-lanes workflow when release investigation needs full Matrix media/E2EE
inventory.
### Reusable Live/E2E Checks
`OpenClaw Live And E2E Checks (Reusable)`
(`openclaw-live-and-e2e-checks-reusable.yml`) is the preferred entry point for
targeted live, Docker, model, and E2E proof. Inputs let you turn off unrelated
lanes:
```bash
gh workflow run openclaw-live-and-e2e-checks-reusable.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<sha> \
-f include_repo_e2e=false \
-f include_release_path_suites=false \
-f include_openwebui=false \
-f include_live_suites=true \
-f live_models_only=true \
-f live_model_providers=fireworks
```
Useful knobs:
- `docker_lanes='<lane[,lane]>'`: run selected Docker scheduler lanes against
prepared artifacts instead of the three release chunks.
- `include_live_suites=false`: skip live/provider suites when testing Docker
scheduler or release packaging only.
- `live_models_only=true`: run only Docker live model coverage.
- `live_model_providers=fireworks` (or comma/space separated providers): run one
targeted Docker live model job instead of the full provider matrix.
- blank `live_model_providers`: run the full live-model provider matrix.
For model-list or provider-selection fixes, use `live_models_only=true` plus the
specific `live_model_providers` allowlist. Confirm logs show the expected
`OPENCLAW_LIVE_PROVIDERS` and selected model ids before declaring proof.
## Docker
Docker is expensive. First inspect the scheduler without running Docker:
```bash
OPENCLAW_DOCKER_ALL_DRY_RUN=1 pnpm test:docker:all
OPENCLAW_DOCKER_ALL_DRY_RUN=1 OPENCLAW_DOCKER_ALL_LANES=install-e2e pnpm test:docker:all
OPENCLAW_DOCKER_ALL_LANES=install-e2e node scripts/test-docker-all.mjs --plan-json
```
Run one failed lane locally only when explicitly asked or when GitHub is not
usable:
```bash
OPENCLAW_DOCKER_ALL_LANES=<lane> \
OPENCLAW_DOCKER_ALL_BUILD=0 \
OPENCLAW_DOCKER_ALL_PREFLIGHT=0 \
OPENCLAW_SKIP_DOCKER_BUILD=1 \
OPENCLAW_DOCKER_E2E_BARE_IMAGE='<prepared-bare-image>' \
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE='<prepared-functional-image>' \
pnpm test:docker:all
```
For release validation, prefer the reusable GitHub workflow input:
```yaml
docker_lanes: install-e2e
```
Multiple lanes are allowed:
```yaml
docker_lanes: install-e2e bundled-channel-update-acpx
```
That skips the release chunk matrix and runs one targeted Docker job against the
prepared GHCR images and the selected package artifact. Rerun commands
generated inside GitHub artifacts include `package_artifact_run_id`,
`package_artifact_name`, `docker_e2e_bare_image`, and
`docker_e2e_functional_image` when available, so failed lanes can reuse the
exact tarball and prepared images from the failed run. When the fix changes
package contents, omit those reuse inputs so the workflow packs a new tarball.
Live-only targeted reruns skip the E2E images and build only the live-test
image. Release-path normal mode remains max three Docker chunk jobs:
- `core`
- `package-update`
- `plugins-integrations`
OpenWebUI is folded into `plugins-integrations` for full release-path coverage
and keeps a standalone `openwebui` chunk only for OpenWebUI-only dispatches.
## Package Acceptance
Use the manual `Package Acceptance` workflow when the question is "does this
installable package work as a product?" rather than "does this source diff pass
Vitest?"
In release validation, treat Package Acceptance as the package-candidate shard
inside the larger release umbrella, not as a competing full-test path. Full
Release Validation and private release gauntlets should call Package Acceptance
for tarball resolution, Docker product/package proof, and optional Telegram QA
against the same resolved `package-under-test` artifact; keep orchestration,
secret policy, blocking/advisory status, and evidence rollup in the caller.
Good defaults:
```bash
gh workflow run package-acceptance.yml --ref main \
-f source=npm \
-f workflow_ref=main \
-f package_spec=openclaw@beta \
-f suite_profile=product \
-f telegram_mode=mock-openai
```
Npm candidate selection:
- Resolve the registry immediately before dispatch:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@beta version dist.tarball dist.integrity --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`.
- If Peter asks for "latest beta", use `source=npm` with
`package_spec=openclaw@beta`, then record the resolved version from `npm view`
or the workflow summary.
- For reruns, release proof, or comparing one known package, prefer the exact
immutable spec: `package_spec=openclaw@YYYY.M.D-beta.N` or
`package_spec=openclaw@YYYY.M.D`.
- For stable package proof, use `package_spec=openclaw@latest` only when the
question is explicitly the current stable dist-tag; otherwise pin the exact
version.
- `source=npm` only accepts registry specs for `openclaw@beta`,
`openclaw@latest`, or exact OpenClaw release versions. Do not pass semver
ranges, git refs, file paths, tarball URLs, or plugin package names there.
- If the candidate is a tarball URL, use `source=url` with `package_sha256`. If
it is an Actions tarball artifact, use `source=artifact`. If it is an
unpublished source candidate, use `source=ref` with a trusted ref or SHA.
- Package acceptance tests exactly the selected package candidate. Do not apply
`openclaw update --channel beta` fallback semantics here; if `beta` is absent,
stale, older than `latest`, or points at a broken tarball, report that tag
state instead of silently testing `latest`.
Profiles:
- `smoke`: quick confidence that the tarball installs, can onboard a channel,
can run an agent turn, and basic gateway/config lanes work.
- `package`: release-package contract. Adds installer/update, doctor install
switching, bundled plugin runtime deps, plugin install/update, and package
repair lanes. This is the default native replacement for most Parallels
package/update coverage.
- `product`: package profile plus broader product surfaces: MCP channels,
cron/subagent cleanup, OpenAI web search, and OpenWebUI.
- `full`: split Docker release-path chunks with OpenWebUI.
- `custom`: exact `docker_lanes` list for a focused rerun.
Candidate sources:
- `source=npm`: `openclaw@beta`, `openclaw@latest`, or an exact release version.
- `source=ref`: pack `package_ref` using the trusted `workflow_ref` harness.
This intentionally separates old package commits from new workflow/test code.
- `source=url`: HTTPS `.tgz` plus required `package_sha256`.
- `source=artifact`: download one `.tgz` from `artifact_run_id`/`artifact_name`.
Ref model:
- `gh workflow run ... --ref <workflow-ref>` selects the workflow file revision
GitHub executes.
- `workflow_ref` is the trusted harness/script ref passed to reusable Docker
E2E.
- `package_ref` is the source ref to build when `source=ref`. It can be an
older branch/tag/SHA as long as it is reachable from an OpenClaw branch or
release tag.
Example: run latest package acceptance harness against an older trusted commit:
```bash
gh workflow run package-acceptance.yml --ref main \
-f workflow_ref=main \
-f source=ref \
-f package_ref=<branch-or-sha> \
-f suite_profile=package \
-f telegram_mode=mock-openai
```
Use `telegram_mode=mock-openai` or `telegram_mode=live-frontier` when the same
resolved `package-under-test` tarball should also run through the Telegram QA
workflow in the `qa-live-shared` environment. The standalone Telegram workflow
still accepts a published npm spec for post-publish checks, but Package
Acceptance passes the resolved artifact for `source=npm`, `ref`, `url`, and
`artifact`. Use `telegram_mode=none` only when intentionally skipping Telegram
credentialed package proof for a focused rerun.
Docker E2E images never copy repo sources as the app under test: the bare image
is a Node/Git runner, and the functional image installs the same prebuilt npm
tarball that bare lanes mount. `scripts/package-openclaw-for-docker.mjs` is the
single packer for local scripts and CI and validates the tarball inventory
before Docker consumes it. `scripts/test-docker-all.mjs --plan-json` is the
scheduler-owned CI plan for image kind, package, live image, lane, and
credential needs. Docker lane definitions live in the single scenario catalog
`scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in
`scripts/lib/docker-e2e-plan.mjs`. `scripts/docker-e2e.mjs` converts plan and
summary JSON into GitHub outputs and step summaries. Every scheduler run writes
`.artifacts/docker-tests/**/summary.json` plus `failures.json`. Read those
before rerunning. Lane entries include `command`, `rerunCommand`, status,
timing, timeout state, image kind, and log file path. The summary also includes
top-level phase timings for preflight, image build, package prep, lane pools,
and cleanup. Use `pnpm test:docker:timings <summary.json>` to rank slow lanes
and phases before deciding whether a broader rerun is justified.
## Cheap Docker Reruns
First derive the smallest rerun command from artifacts:
```bash
pnpm test:docker:rerun <github-run-id>
pnpm test:docker:rerun .artifacts/docker-tests/<run>/failures.json
```
The script downloads Docker E2E artifacts for a GitHub run, reads
`summary.json`/`failures.json`, and prints a combined targeted workflow command
plus per-lane commands. Prefer the combined targeted command when several lanes
failed for the same patch:
```bash
gh workflow run openclaw-live-and-e2e-checks-reusable.yml \
-f ref=<sha> \
-f include_repo_e2e=false \
-f include_release_path_suites=false \
-f include_openwebui=false \
-f docker_lanes='install-e2e bundled-channel-update-acpx' \
-f include_live_suites=false \
-f live_models_only=false
```
That path still runs the prepare job, so it creates a new tarball for `<sha>`.
If the SHA-tagged GHCR bare/functional image already exists, CI skips rebuilding
that image and only uploads the fresh package artifact before the targeted lane
job. Do not rerun the full three-chunk release path unless the failed lane list
or touched surface really requires it.
## Docker Expected Timings
Treat these as ballpark. Blacksmith queue time, GHCR pull speed, provider
latency, npm cache state, and Docker daemon health can dominate.
Current local timing artifact (`.artifacts/docker-tests/lane-timings.json`) has
these rough bands:
- Tiny lanes, seconds to under 1 minute:
`agents-delete-shared-workspace` ~3s, `plugin-update` ~7s,
`config-reload` ~14s, `pi-bundle-mcp-tools` ~15s, `onboard` ~18s,
`session-runtime-context` ~20s, `gateway-network` ~34s, `qr` ~44s.
- Medium deterministic lanes, ~1-5 minutes:
`npm-onboard-channel-agent` ~96s, `openai-image-auth` ~99s,
bundled channel/update lanes usually ~90-300s, `openwebui` ~225s,
`mcp-channels` ~274s.
- Heavy deterministic lanes, ~6-10 minutes:
`bundled-channel-root-owned` ~429s,
`bundled-channel-setup-entry` ~420s,
`bundled-channel-load-failure` ~383s,
`cron-mcp-cleanup` ~567s.
- Live provider lanes, often ~15-20 minutes:
`live-gateway` ~958s, `live-models` ~1054s.
- Installer/release lanes:
`install-e2e` and package-update paths can vary widely with npm, provider,
and package registry behavior. Budget tens of minutes; prefer GitHub targeted
reruns over local repeats.
Default fallback lane timeout is 120 minutes. A timeout usually means debug the
lane log/artifacts first, not “run the whole thing again.”
## Failure Workflow
1. Identify exact failing job, SHA, lane, and artifact path.
2. Read `failures.json`, `summary.json`, and the failed lane log tail.
3. Use `pnpm test:docker:rerun <run-id|failures.json>` to generate targeted
GitHub rerun commands.
4. If the lane has `rerunCommand`, use that only as a local starting point.
5. For Docker release failures, dispatch targeted `docker_lanes=<failed-lane>`
on GitHub before considering local Docker.
6. Patch narrowly, then rerun the failed file/lane only.
7. Broaden to `pnpm check:changed` or CI only after the isolated proof passes.
## When To Escalate
- Public SDK/plugin contract changes: run changed gate plus relevant extension
validation.
- Build output, lazy imports, package boundaries, or published surfaces:
include `pnpm build`.
- Workflow edits: run `pnpm check:workflows`.
- Release branch or tag validation: use release docs and GitHub workflows; avoid
local Docker unless Peter explicitly asks.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "OpenClaw Testing"
short_description: "Choose cheap, targeted OpenClaw validation"
default_prompt: "Use $openclaw-testing to choose the cheapest safe test or CI verification path, inspect failures, and rerun only the relevant OpenClaw lane."

View File

@@ -1,149 +0,0 @@
name: Docker E2E plan and hydrate
description: >
Create a Docker E2E lane plan, expose GitHub outputs, and optionally hydrate
the prebuilt package artifact plus shared Docker images needed by the plan.
inputs:
mode:
description: prepare, chunk, or targeted.
required: true
chunk:
description: Release-path chunk for mode=chunk.
required: false
default: ""
lanes:
description: Comma/space separated lane names for targeted or prepare mode.
required: false
default: ""
include-openwebui:
description: Whether Open WebUI is included when planning release/prepare coverage.
required: false
default: "true"
include-release-path-suites:
description: Whether prepare mode should plan all release-path suites.
required: false
default: "false"
hydrate-artifacts:
description: Whether to download/pull artifacts required by the plan.
required: false
default: "true"
package-artifact-name:
description: Workflow artifact name containing openclaw-current.tgz.
required: false
default: docker-e2e-package
outputs:
credentials:
description: Comma-separated credential groups required by selected lanes.
value: ${{ steps.plan.outputs.credentials }}
needs_bare_image:
description: "1 when selected lanes require the bare Docker E2E image."
value: ${{ steps.plan.outputs.needs_bare_image }}
needs_e2e_image:
description: "1 when selected lanes require any Docker E2E image."
value: ${{ steps.plan.outputs.needs_e2e_image }}
needs_functional_image:
description: "1 when selected lanes require the functional Docker E2E image."
value: ${{ steps.plan.outputs.needs_functional_image }}
needs_live_image:
description: "1 when selected lanes require building the live Docker image."
value: ${{ steps.plan.outputs.needs_live_image }}
needs_package:
description: "1 when selected lanes require the OpenClaw package tarball."
value: ${{ steps.plan.outputs.needs_package }}
plan_json:
description: Path to the generated plan JSON.
value: ${{ steps.plan.outputs.plan_json }}
runs:
using: composite
steps:
- name: Plan Docker E2E lanes
id: plan
shell: bash
env:
MODE: ${{ inputs.mode }}
CHUNK: ${{ inputs.chunk }}
LANES: ${{ inputs.lanes }}
INCLUDE_OPENWEBUI: ${{ inputs.include-openwebui }}
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include-release-path-suites }}
run: |
set -euo pipefail
mkdir -p .artifacts/docker-tests
case "$MODE" in
prepare)
plan_path=".artifacts/docker-tests/plan.json"
if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL=1
elif [[ -n "$LANES" ]]; then
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
elif [[ "$INCLUDE_OPENWEBUI" == "true" ]]; then
export OPENCLAW_DOCKER_ALL_LANES=openwebui
fi
;;
chunk)
if [[ -z "$CHUNK" ]]; then
echo "chunk input is required for Docker E2E chunk planning." >&2
exit 1
fi
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK="$CHUNK"
plan_path=".artifacts/docker-tests/release-${CHUNK}-plan.json"
;;
targeted)
if [[ -z "$LANES" ]]; then
echo "lanes input is required for Docker E2E targeted planning." >&2
exit 1
fi
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
plan_path=".artifacts/docker-tests/targeted-plan.json"
;;
*)
echo "mode must be prepare, chunk, or targeted. Got: $MODE" >&2
exit 1
;;
esac
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_package == '1'
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package-artifact-name }}
path: .artifacts/docker-e2e-package
- name: Pull shared bare Docker E2E image
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_bare_image == '1'
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
if: inputs.hydrate-artifacts == 'true'
shell: bash
env:
CREDENTIALS: ${{ steps.plan.outputs.credentials }}
run: |
set -euo pipefail
credentials=",$CREDENTIALS,"
if [[ "$credentials" == *",openai,"* ]]; then
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for selected Docker E2E lanes." >&2
exit 1
}
fi
if [[ "$credentials" == *",anthropic,"* && -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for selected Docker E2E lanes." >&2
exit 1
fi

View File

@@ -1,8 +1,7 @@
name: openclaw-codeql-javascript-typescript
name: openclaw-codeql-javascript-typescript-core
paths:
- src
- extensions
- ui/src
- skills

View File

@@ -0,0 +1,18 @@
name: openclaw-codeql-javascript-typescript-extensions
paths:
- extensions
paths-ignore:
- apps
- dist
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"

4
.github/labeler.yml vendored
View File

@@ -233,10 +233,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:

View File

@@ -1,198 +0,0 @@
name: Blacksmith Build Artifacts Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
pull_request:
paths:
- ".github/workflows/**"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-artifacts:
permissions:
contents: read
name: "build-artifacts"
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 35
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@v2
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
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"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Resolve release dist cache seeds
id: dist-cache-seeds
shell: bash
run: |
set -euo pipefail
cache_prefix="${RUNNER_OS}-dist-build-"
declare -A seen=()
resolve_tag_sha() {
local tag="$1"
local direct=""
local peeled=""
while read -r sha ref; do
if [[ "$ref" == "refs/tags/${tag}^{}" ]]; then
peeled="$sha"
elif [[ "$ref" == "refs/tags/${tag}" ]]; then
direct="$sha"
fi
done < <(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}")
printf '%s\n' "${peeled:-$direct}"
}
{
echo "restore-keys<<EOF"
for dist_tag in beta latest; do
version="$(npm view "openclaw@${dist_tag}" version 2>/dev/null || true)"
if [[ -z "$version" ]]; then
echo "Could not resolve npm dist-tag ${dist_tag}; skipping cache seed." >&2
continue
fi
sha="$(resolve_tag_sha "v${version}")"
if [[ -z "$sha" ]]; then
echo "Could not resolve git tag v${version}; skipping cache seed." >&2
continue
fi
key="${cache_prefix}${sha}"
if [[ -z "${seen[$key]+x}" ]]; then
echo "$key"
seen[$key]=1
fi
done
echo "${cache_prefix}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Restore dist build cache
id: dist-cache
uses: actions/cache/restore@v5
with:
path: |
.artifacts/build-all-cache/
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
restore-keys: ${{ steps.dist-cache-seeds.outputs.restore-keys }}
- name: Build dist on cache miss
if: steps.dist-cache.outputs.cache-hit != 'true'
run: pnpm build:ci-artifacts
- name: Build Control UI on cache miss
if: steps.dist-cache.outputs.cache-hit != 'true'
run: pnpm ui:build
- name: Verify build artifacts
shell: bash
run: |
set -euo pipefail
test -d dist
test -d dist-runtime
if [[ ! -f dist/index.js && ! -f dist/index.mjs ]]; then
echo "Missing dist/index.js or dist/index.mjs" >&2
exit 1
fi
test -f dist/build-info.json
test -f dist/control-ui/index.html
- name: Save dist build cache
if: steps.dist-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: |
.artifacts/build-all-cache/
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
pnpm_bin="$(command -v pnpm)"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -1,13 +1,6 @@
name: CI
on:
workflow_dispatch:
inputs:
target_ref:
description: Optional branch, tag, or full commit SHA to validate instead of the workflow ref
required: false
default: ""
type: string
push:
branches: [main]
paths-ignore:
@@ -20,8 +13,8 @@ permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
group: ${{ github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha)) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -36,7 +29,6 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
checkout_sha: ${{ steps.checkout_ref.outputs.sha }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
@@ -45,6 +37,8 @@ jobs:
run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
run_skills_python_job: ${{ steps.manifest.outputs.run_skills_python_job }}
run_windows: ${{ steps.manifest.outputs.run_windows }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
@@ -57,6 +51,8 @@ jobs:
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
@@ -73,18 +69,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Resolve checkout SHA
id: checkout_ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Ensure preflight base commit
if: github.event_name != 'workflow_dispatch'
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
@@ -92,12 +82,11 @@ jobs:
- name: Detect docs-only changes
id: docs_scope
if: github.event_name != 'workflow_dispatch'
uses: ./.github/actions/detect-docs-changes
- name: Detect changed scopes
id: changed_scope
if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true'
if: steps.docs_scope.outputs.docs_only != 'true'
shell: bash
run: |
set -euo pipefail
@@ -110,20 +99,45 @@ jobs:
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
- name: Detect changed extensions
id: changed_extensions
if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
head: "HEAD",
fallbackBaseRef: process.env.BASE_REF,
unavailableBaseBehavior: "all",
});
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
EOF
- name: Build CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ steps.changed_scope.outputs.run_node_fast_only || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
run: |
node --input-type=module <<'EOF'
@@ -147,8 +161,18 @@ jobs:
return fallback;
};
const parseJson = (value, fallback) => {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
};
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request";
const isPush = eventName === "push";
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
@@ -173,6 +197,11 @@ jobs:
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const runControlUiI18n =
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
const hasChangedExtensions =
parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
const changedExtensionsMatrix = hasChangedExtensions
? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] })
: { include: [] };
const extensionTestShardCount = isCanonicalRepository
? DEFAULT_EXTENSION_TEST_SHARD_COUNT
: Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36);
@@ -242,6 +271,8 @@ jobs:
run_android: runAndroid,
run_skills_python: runSkillsPython,
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
run_build_artifacts: runNodeFull,
run_checks_fast_core: runChecksFastCore,
run_checks_fast: runNodeFull,
@@ -262,6 +293,15 @@ jobs:
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_extension_fast: hasChangedExtensions && !isPush,
extension_fast_matrix: createMatrix(
hasChangedExtensions && !isPush
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
}))
: [],
),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_build_smoke: runNodeFull,
@@ -314,14 +354,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure security base commit
if: github.event_name != 'workflow_dispatch'
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
@@ -405,7 +443,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
@@ -468,7 +505,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -540,7 +577,7 @@ jobs:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_sha }}
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -669,7 +706,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -764,7 +801,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -867,7 +904,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -935,7 +972,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1047,7 +1084,7 @@ jobs:
contents: read
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
@@ -1055,7 +1092,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1135,7 +1172,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1286,6 +1323,84 @@ jobs:
exit 1
fi
extension-fast:
permissions:
contents: read
name: "extension-fast"
needs: [preflight]
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
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"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run changed extension tests
env:
OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
run: |
set -euo pipefail
if [ "$OPENCLAW_CHANGED_EXTENSION" = "telegram" ]; then
export OPENCLAW_VITEST_MAX_WORKERS=1
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=6144"
pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" -- --pool=forks
exit 0
fi
pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"
# Types, lint, and format check shards.
check-shard:
permissions:
@@ -1322,7 +1437,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1454,7 +1569,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1652,7 +1767,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1715,7 +1830,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
persist-credentials: false
submodules: false
@@ -1758,7 +1872,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
persist-credentials: false
submodules: false
@@ -1863,7 +1976,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
persist-credentials: false
submodules: false
@@ -1904,7 +2016,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
persist-credentials: false
submodules: false
@@ -2005,7 +2116,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

View File

@@ -19,13 +19,14 @@ permissions:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
name: Analyze (${{ matrix.job_name }})
runs-on: ${{ matrix.runs_on }}
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
- job_name: javascript-typescript-core
language: javascript-typescript
runs_on: blacksmith-32vcpu-ubuntu-2404
needs_node: true
needs_python: false
@@ -33,8 +34,21 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ./.github/codeql/codeql-javascript-typescript.yml
- language: actions
analyze_category: javascript-typescript-core
config_file: ./.github/codeql/codeql-javascript-typescript-core.yml
- job_name: javascript-typescript-extensions
language: javascript-typescript
runs_on: blacksmith-32vcpu-ubuntu-2404
needs_node: true
needs_python: false
needs_java: false
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
analyze_category: javascript-typescript-extensions
config_file: ./.github/codeql/codeql-javascript-typescript-extensions.yml
- job_name: actions
language: actions
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: false
@@ -42,8 +56,10 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
analyze_category: actions
config_file: ""
- language: python
- job_name: python
language: python
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: true
@@ -51,8 +67,10 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
analyze_category: python
config_file: ""
- language: java-kotlin
- job_name: java-kotlin
language: java-kotlin
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: false
@@ -60,8 +78,10 @@ jobs:
needs_swift_tools: false
needs_manual_build: true
needs_autobuild: false
analyze_category: java-kotlin
config_file: ""
- language: swift
- job_name: swift
language: swift
runs_on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
needs_node: false
needs_python: false
@@ -69,6 +89,7 @@ jobs:
needs_swift_tools: true
needs_manual_build: true
needs_autobuild: false
analyze_category: swift
config_file: ""
steps:
- name: Checkout
@@ -135,4 +156,4 @@ jobs:
- name: Analyze
uses: github/codeql-action/analyze@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
with:
category: "/language:${{ matrix.language }}"
category: "/language:${{ matrix.analyze_category }}"

View File

@@ -63,7 +63,7 @@ jobs:
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
# Build amd64 image. Default and slim tags point to the same slim runtime.
# Build amd64 images (default + slim share the build stage cache)
build-amd64:
needs: [approve_manual_backfill]
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
@@ -74,6 +74,7 @@ jobs:
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
slim-digest: ${{ steps.build-slim.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -116,7 +117,12 @@ jobs:
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "slim<<EOF"
printf "%s\n" "${slim_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
@@ -153,15 +159,28 @@ jobs:
platforms: linux/amd64
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
provenance: mode=max
provenance: false
push: true
# Build arm64 image. Default and slim tags point to the same slim runtime.
- name: Build and push amd64 slim image
id: build-slim
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
build-args: |
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
# Build arm64 images (default + slim share the build stage cache)
build-arm64:
needs: [approve_manual_backfill]
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
@@ -172,6 +191,7 @@ jobs:
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
slim-digest: ${{ steps.build-slim.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -214,7 +234,12 @@ jobs:
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "slim<<EOF"
printf "%s\n" "${slim_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
@@ -251,12 +276,25 @@ jobs:
platforms: linux/arm64
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
provenance: mode=max
provenance: false
push: true
- name: Build and push arm64 slim image
id: build-slim
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/arm64
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
build-args: |
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
# Create multi-platform manifests
@@ -313,11 +351,16 @@ jobs:
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "slim<<EOF"
printf "%s\n" "${slim_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create and push manifest
- name: Create and push default manifest
shell: bash
env:
TAGS: ${{ steps.tags.outputs.value }}
@@ -335,94 +378,20 @@ jobs:
"${AMD64_DIGEST}" \
"${ARM64_DIGEST}"
verify-attestations:
needs: [create-manifest]
if: ${{ always() && needs.create-manifest.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Set up Docker Builder
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image refs
id: refs
- name: Create and push slim manifest
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
SLIM_TAGS: ${{ steps.tags.outputs.slim }}
AMD64_SLIM_DIGEST: ${{ needs.build-amd64.outputs.slim-digest }}
ARM64_SLIM_DIGEST: ${{ needs.build-arm64.outputs.slim-digest }}
run: |
set -euo pipefail
multi_refs=()
slim_multi_refs=()
amd64_refs=()
arm64_refs=()
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
multi_refs+=("${IMAGE}:main")
slim_multi_refs+=("${IMAGE}:main-slim")
amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64")
arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64")
fi
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
version="${SOURCE_REF#refs/tags/v}"
multi_refs+=("${IMAGE}:${version}")
slim_multi_refs+=("${IMAGE}:${version}-slim")
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
multi_refs+=("${IMAGE}:latest")
slim_multi_refs+=("${IMAGE}:slim")
fi
fi
if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then
echo "::error::No Docker image refs resolved for ref ${SOURCE_REF}"
exit 1
fi
{
echo "multi<<EOF"
printf "%s\n" "${multi_refs[@]}" "${slim_multi_refs[@]}"
echo "EOF"
echo "amd64<<EOF"
printf "%s\n" "${amd64_refs[@]}"
echo "EOF"
echo "arm64<<EOF"
printf "%s\n" "${arm64_refs[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Verify Docker attestations
shell: bash
env:
MULTI_REFS: ${{ steps.refs.outputs.multi }}
AMD64_REFS: ${{ steps.refs.outputs.amd64 }}
ARM64_REFS: ${{ steps.refs.outputs.arm64 }}
run: |
set -euo pipefail
mapfile -t multi_refs <<< "${MULTI_REFS}"
mapfile -t amd64_refs <<< "${AMD64_REFS}"
mapfile -t arm64_refs <<< "${ARM64_REFS}"
node scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
--platform linux/arm64 \
"${multi_refs[@]}"
node scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
"${amd64_refs[@]}"
node scripts/verify-docker-attestations.mjs \
--platform linux/arm64 \
"${arm64_refs[@]}"
mapfile -t tags <<< "${SLIM_TAGS}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
"${AMD64_SLIM_DIGEST}" \
"${ARM64_SLIM_DIGEST}"

View File

@@ -197,8 +197,7 @@ jobs:
- 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
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"

View File

@@ -1,391 +0,0 @@
name: Full Release Validation
on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag, or full commit SHA to validate
required: true
default: main
type: string
workflow_ref:
description: Trusted workflow ref used to run child workflows
required: false
default: main
type: string
provider:
description: Provider lane for cross-OS onboarding and the end-to-end agent turn
required: false
default: openai
type: choice
options:
- openai
- anthropic
- minimax
mode:
description: Which cross-OS release lanes to run
required: false
default: both
type: choice
options:
- fresh
- upgrade
- both
npm_telegram_package_spec:
description: Optional published package spec for the post-publish Telegram E2E lane
required: false
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the optional post-publish Telegram E2E lane
required: false
default: mock-openai
type: choice
options:
- mock-openai
- live-frontier
npm_telegram_scenario:
description: Optional comma-separated Telegram scenario ids for the post-publish lane
required: false
default: ""
type: string
permissions:
actions: write
contents: read
concurrency:
group: full-release-validation-${{ inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
GH_REPO: ${{ github.repository }}
jobs:
resolve_target:
name: Resolve target ref
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
sha: ${{ steps.resolve.outputs.sha }}
steps:
- name: Checkout target ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Resolve target SHA
id: resolve
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Summarize target
env:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ steps.resolve.outputs.sha }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }}
run: |
{
echo "## Full release validation"
echo
echo "- Target ref: \`${TARGET_REF}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Child workflow ref: \`${WORKFLOW_REF}\`"
echo "- Normal CI: \`CI\` with \`target_ref=${TARGET_REF}\`"
echo "- Release/live/Docker/package/QA: \`OpenClaw Release Checks\`"
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Post-publish Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
else
echo "- Post-publish Telegram E2E: skipped because no published package spec was provided"
fi
} >> "$GITHUB_STEP_SUMMARY"
normal_ci:
name: Run normal full CI
needs: [resolve_target]
runs-on: ubuntu-24.04
timeout-minutes: 240
steps:
- name: Dispatch and monitor CI
env:
GH_TOKEN: ${{ github.token }}
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
run: |
set -euo pipefail
dispatch_and_wait() {
local workflow="$1"
local workflow_ref="$2"
shift 2
local before_json run_id status conclusion url
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
gh workflow run "$workflow" --ref "$workflow_ref" "$@"
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
break
fi
sleep 30
done
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
echo "${workflow} finished with ${conclusion}: ${url}"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}'
exit 1
fi
}
{
echo "### Normal CI"
echo
echo "- Target ref: \`${TARGET_REF}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
dispatch_and_wait ci.yml "$WORKFLOW_REF" -f target_ref="$TARGET_REF"
release_checks:
name: Run release/live/Docker/QA validation
needs: [resolve_target]
runs-on: ubuntu-24.04
timeout-minutes: 720
steps:
- name: Dispatch and monitor release checks
env:
GH_TOKEN: ${{ github.token }}
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
PROVIDER: ${{ inputs.provider }}
MODE: ${{ inputs.mode }}
run: |
set -euo pipefail
dispatch_and_wait() {
local workflow="$1"
local workflow_ref="$2"
shift 2
local before_json run_id status conclusion url
before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
gh workflow run "$workflow" --ref "$workflow_ref" "$@"
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
break
fi
sleep 60
done
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
echo "${workflow} finished with ${conclusion}: ${url}"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}'
exit 1
fi
}
{
echo "### Release/live/Docker/QA validation"
echo
echo "- Target ref: \`${TARGET_REF}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Provider: \`${PROVIDER}\`"
echo "- Cross-OS mode: \`${MODE}\`"
} >> "$GITHUB_STEP_SUMMARY"
dispatch_and_wait openclaw-release-checks.yml "$WORKFLOW_REF" \
-f ref="$TARGET_REF" \
-f provider="$PROVIDER" \
-f mode="$MODE"
npm_telegram:
name: Run post-publish Telegram E2E
needs: [resolve_target]
if: inputs.npm_telegram_package_spec != ''
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- name: Dispatch and monitor npm Telegram E2E
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }}
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
SCENARIO: ${{ inputs.npm_telegram_scenario }}
run: |
set -euo pipefail
before_json="$(gh run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
args=(-f package_spec="$PACKAGE_SPEC" -f provider_mode="$PROVIDER_MODE")
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
fi
gh workflow run npm-telegram-beta-e2e.yml --ref "$WORKFLOW_REF" "${args[@]}"
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
exit 1
fi
echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
break
fi
sleep 60
done
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
echo "npm-telegram-beta-e2e.yml finished with ${conclusion}: ${url}"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}'
exit 1
fi
summary:
name: Verify full validation
needs: [normal_ci, release_checks, npm_telegram]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::error::Could not derive release evidence id from target ref '${TARGET_REF}'."
exit 1
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"
- name: Verify child workflow results
env:
NORMAL_CI_RESULT: ${{ needs.normal_ci.result }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
run: |
set -euo pipefail
failed=0
for item in \
"normal_ci=${NORMAL_CI_RESULT}" \
"release_checks=${RELEASE_CHECKS_RESULT}" \
"npm_telegram=${NPM_TELEGRAM_RESULT}"
do
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
echo "::error::${name} ended with ${result}"
failed=1
fi
done
exit "$failed"

View File

@@ -10,11 +10,6 @@ on:
required: false
default: false
type: boolean
update_baseline_version:
description: Baseline openclaw version or dist-tag for installer update smoke
required: false
default: latest
type: string
workflow_call:
inputs:
ref:
@@ -26,11 +21,6 @@ on:
required: false
default: true
type: boolean
update_baseline_version:
description: Baseline openclaw version or dist-tag for installer update smoke
required: false
default: latest
type: string
permissions:
contents: read
@@ -113,6 +103,7 @@ jobs:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_DOCKER_APT_UPGRADE=0
OPENCLAW_EXTENSIONS=matrix
tags: |
openclaw-dockerfile-smoke:local
@@ -123,21 +114,7 @@ jobs:
- 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}`);
}
}
"
'
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run agents delete shared workspace Docker CLI smoke
env:
@@ -227,6 +204,7 @@ jobs:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_DOCKER_APT_UPGRADE=0
OPENCLAW_EXTENSIONS=matrix
tags: |
openclaw-dockerfile-smoke:local
@@ -340,7 +318,7 @@ jobs:
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: "0"
OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}
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

View File

@@ -4,20 +4,10 @@ on:
workflow_dispatch:
inputs:
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
description: Published OpenClaw package spec to test
required: true
default: openclaw@beta
type: string
package_label:
description: Optional display label for an artifact-backed package candidate
required: false
default: ""
type: string
package_artifact_name:
description: Advanced package-under-test artifact name; leave blank for registry install
required: false
default: ""
type: string
provider_mode:
description: QA provider mode
required: true
@@ -30,39 +20,6 @@ on:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
workflow_call:
inputs:
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
type: string
package_artifact_name:
description: Optional package-under-test artifact from the current workflow run
required: false
default: ""
type: string
package_label:
description: Optional display label for an artifact-backed package candidate
required: false
default: ""
type: string
provider_mode:
description: QA provider mode
required: false
default: mock-openai
type: string
scenario:
description: Optional comma-separated Telegram scenario ids
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
OPENCLAW_QA_CONVEX_SITE_URL:
required: false
OPENCLAW_QA_CONVEX_SECRET_CI:
required: false
permissions:
contents: read
@@ -77,39 +34,106 @@ env:
PNPM_VERSION: "10.33.0"
jobs:
run_package_telegram_e2e:
name: Run package Telegram E2E
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}"
prepare_docker_e2e_image:
name: Prepare Docker E2E image
needs: validate_dispatch_ref
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
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 dispatch ref
- name: Checkout main
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
- name: Resolve Docker E2E image tag
id: image
shell: bash
env:
SELECTED_SHA: ${{ github.sha }}
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
image="ghcr.io/${repository}-docker-e2e:${SELECTED_SHA}"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "Docker E2E image: \`$image\`" >> "$GITHUB_STEP_SUMMARY"
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build Docker E2E image
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push 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
tags: ${{ steps.image.outputs.image }}
provenance: false
push: true
run_npm_telegram_beta_e2e:
name: Run published npm Telegram E2E
needs: [approve_release_manager, prepare_docker_e2e_image]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
permissions:
contents: read
packages: read
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
- name: Log in to GHCR
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
@@ -121,7 +145,6 @@ jobs:
- name: Validate inputs and secrets
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
@@ -130,19 +153,10 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
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
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
case "${PROVIDER_MODE}" in
mock-openai | live-frontier) ;;
*)
echo "provider_mode must be mock-openai or live-frontier; got: ${PROVIDER_MODE}" >&2
exit 1
;;
esac
require_var() {
local key="$1"
@@ -158,31 +172,21 @@ jobs:
require_var OPENAI_API_KEY
fi
- name: Download package-under-test artifact
if: inputs.package_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/telegram-package-under-test
- name: Run package Telegram E2E
- 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_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.package_spec }}
OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL: ${{ inputs.package_label }}
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"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
run: |
set -euo pipefail
@@ -190,20 +194,6 @@ jobs:
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
export OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="${output_dir}"
if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then
mapfile -t package_tgzs < <(find .artifacts/telegram-package-under-test -type f -name "*.tgz" | sort)
if [[ "${#package_tgzs[@]}" -ne 1 ]]; then
echo "package artifact ${PACKAGE_ARTIFACT_NAME} must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2
exit 1
fi
export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"
if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgzs[0]}")"
fi
elif [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}"
fi
if [[ -n "${INPUT_SCENARIO// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_SCENARIOS="${INPUT_SCENARIO}"
fi

View File

@@ -23,31 +23,6 @@ on:
required: false
default: true
type: boolean
docker_lanes:
description: Comma/space separated Docker scheduler lane names to run against the prepared image
required: false
default: ""
type: string
package_artifact_name:
description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref
required: false
default: ""
type: string
package_artifact_run_id:
description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref
required: false
default: ""
type: string
docker_e2e_bare_image:
description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
docker_e2e_functional_image:
description: Existing functional Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -58,11 +33,6 @@ on:
required: false
default: false
type: boolean
live_model_providers:
description: Comma/space separated provider ids for the Docker live model matrix; blank runs all providers
required: false
default: ""
type: string
workflow_call:
inputs:
ref:
@@ -84,31 +54,6 @@ on:
required: false
default: true
type: boolean
docker_lanes:
description: Comma/space separated Docker scheduler lane names to run against the prepared image
required: false
default: ""
type: string
package_artifact_name:
description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref
required: false
default: ""
type: string
package_artifact_run_id:
description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref
required: false
default: ""
type: string
docker_e2e_bare_image:
description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
docker_e2e_functional_image:
description: Existing functional Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -119,11 +64,6 @@ on:
required: false
default: false
type: boolean
live_model_providers:
description: Comma/space separated provider ids for the Docker live model matrix; blank runs all providers
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -240,6 +180,7 @@ jobs:
- name: Validate selected ref
id: validate
env:
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
@@ -247,22 +188,27 @@ jobs:
selected_sha="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
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 git for-each-ref --format='%(refname:short)' --contains "$selected_sha" refs/remotes/origin | grep -Eq '^origin/'; then
trusted_reason="repository-branch-history"
else
trusted_reason=""
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
trusted_reason="open-pr-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
echo "Allowed refs must be reachable from an OpenClaw branch or release tag." >&2
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
@@ -357,7 +303,7 @@ jobs:
requires_live_suites: false
- suite_id: openai-ws-stream-live-e2e
label: OpenAI WebSocket live E2E
command: pnpm test:e2e src/agents/openai-ws-stream.e2e.test.ts
command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
timeout_minutes: 90
requires_repo_e2e: false
requires_live_suites: true
@@ -417,23 +363,88 @@ jobs:
validate_docker_e2e:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (${{ matrix.label }})
if: inputs.include_release_path_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- chunk_id: core
label: core
- suite_id: docker-onboard
label: Onboarding Docker E2E
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
- 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
- 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
- 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
- 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
- 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
- suite_id: docker-qr
label: QR Import Docker E2E
command: pnpm test:docker:qr
timeout_minutes: 60
release_path: true
- suite_id: docker-install-e2e
label: Installer Docker E2E
command: pnpm test:install:e2e
timeout_minutes: 120
- chunk_id: package-update
label: package/update
timeout_minutes: 180
- chunk_id: plugins-integrations
label: plugins/integrations
timeout_minutes: 180
release_path: true
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -480,13 +491,7 @@ jobs:
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_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -511,194 +516,45 @@ jobs:
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Plan and hydrate Docker E2E chunk
id: plan
uses: ./.github/actions/docker-e2e-plan
with:
mode: chunk
chunk: ${{ matrix.chunk_id }}
include-openwebui: ${{ inputs.include_openwebui }}
package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
- name: Run Docker E2E chunk
- name: Configure suite-specific env
shell: bash
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK="${DOCKER_E2E_CHUNK}"
export OPENCLAW_DOCKER_ALL_BUILD=0
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
case "${{ matrix.suite_id }}" in
docker-install-e2e)
echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV"
;;
esac
pnpm test:docker:all
- name: Summarize Docker E2E chunk
if: always()
- name: Validate suite credentials
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
case "${{ matrix.suite_id }}" in
docker-install-e2e)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
exit 1
}
if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2
exit 1
fi
;;
esac
- name: Upload Docker E2E chunk artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-${{ matrix.chunk_id }}
path: .artifacts/docker-tests/
if-no-files-found: ignore
validate_docker_lanes:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 180
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
DOCKER_E2E_LANES: ${{ inputs.docker_lanes }}
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: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Plan and hydrate targeted Docker E2E lanes
id: plan
uses: ./.github/actions/docker-e2e-plan
with:
mode: targeted
lanes: ${{ inputs.docker_lanes }}
include-openwebui: ${{ inputs.include_openwebui }}
package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
- name: Run targeted Docker E2E lanes
shell: bash
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_LANES="${DOCKER_E2E_LANES}"
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
pnpm test:docker:live-build
fi
export OPENCLAW_DOCKER_ALL_BUILD=0
pnpm test:docker:all
- name: Summarize targeted Docker E2E lanes
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/targeted/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
- name: Upload targeted Docker E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-targeted
path: .artifacts/docker-tests/
if-no-files-found: ignore
- name: Run ${{ matrix.label }}
run: ${{ matrix.command }}
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (openwebui)
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_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
steps:
- name: Checkout selected ref
@@ -730,69 +586,19 @@ jobs:
exit 1
}
- name: Plan and hydrate Open WebUI Docker E2E chunk
id: plan
uses: ./.github/actions/docker-e2e-plan
with:
mode: chunk
chunk: openwebui
include-openwebui: "true"
package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
- name: Run Open WebUI Docker E2E chunk
shell: bash
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK=openwebui
export OPENCLAW_DOCKER_ALL_BUILD=0
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI=1
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-openwebui"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-openwebui-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
pnpm test:docker:all
- name: Summarize Open WebUI Docker E2E chunk
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/release-openwebui/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Open WebUI Docker E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-openwebui
path: .artifacts/docker-tests/
if-no-files-found: ignore
- 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 || inputs.docker_lanes != ''
if: inputs.include_release_path_suites || inputs.include_openwebui
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
permissions:
actions: read
contents: read
packages: write
outputs:
image: ${{ steps.image.outputs.image }}
bare_image: ${{ steps.image.outputs.bare_image }}
functional_image: ${{ steps.image.outputs.functional_image }}
needs_bare_image: ${{ steps.plan.outputs.needs_bare_image }}
needs_e2e_image: ${{ steps.plan.outputs.needs_e2e_image }}
needs_functional_image: ${{ steps.plan.outputs.needs_functional_image }}
needs_live_image: ${{ steps.plan.outputs.needs_live_image }}
needs_package: ${{ steps.plan.outputs.needs_package }}
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -803,191 +609,45 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Plan Docker E2E images
id: plan
uses: ./.github/actions/docker-e2e-plan
with:
mode: prepare
lanes: ${{ inputs.docker_lanes }}
include-release-path-suites: ${{ inputs.include_release_path_suites }}
include-openwebui: ${{ inputs.include_openwebui }}
hydrate-artifacts: "false"
- name: Setup Node environment
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == ''
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download current-run OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name != '' && inputs.package_artifact_run_id == ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/docker-e2e-package
- name: Download previous-run OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_run_id != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
path: .artifacts/docker-e2e-package
run-id: ${{ inputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Pack OpenClaw package for Docker E2E
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == ''
shell: bash
run: |
set -euo pipefail
mkdir -p .artifacts/docker-e2e-package
node scripts/package-openclaw-for-docker.mjs \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz
- name: Validate OpenClaw Docker E2E package
id: package
if: steps.plan.outputs.needs_package == '1'
shell: bash
run: |
set -euo pipefail
mkdir -p .artifacts/docker-e2e-package
target=".artifacts/docker-e2e-package/openclaw-current.tgz"
if [[ ! -f "$target" ]]; then
mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort)
if [[ "${#tgzs[@]}" -ne 1 ]]; then
echo "Expected exactly one package tarball in .artifacts/docker-e2e-package; found ${#tgzs[@]}." >&2
printf '%s\n' "${tgzs[@]}" >&2
exit 1
fi
cp "${tgzs[0]}" "$target"
fi
node scripts/check-openclaw-package-tarball.mjs "$target"
digest="$(sha256sum "$target" | awk '{print $1}')"
tag="pkg-${digest:0:32}"
echo "sha256=$digest" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
{
echo "Docker E2E package: \`$target\`"
echo "Docker E2E package SHA-256: \`$digest\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && (inputs.package_artifact_name == '' || inputs.package_artifact_run_id != '')
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
path: .artifacts/docker-e2e-package/openclaw-current.tgz
if-no-files-found: error
- name: Resolve shared Docker E2E image tags
- name: Resolve shared Docker E2E image tag
id: image
shell: bash
env:
PACKAGE_TAG: ${{ steps.package.outputs.tag }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }}
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
image_tag="${PACKAGE_TAG:-$SELECTED_SHA}"
bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}"
functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}"
image="$functional_image"
image="ghcr.io/${repository}-docker-e2e:${SELECTED_SHA}"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT"
echo "functional_image=$functional_image" >> "$GITHUB_OUTPUT"
echo "Shared Docker E2E bare image: \`$bare_image\`" >> "$GITHUB_STEP_SUMMARY"
echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY"
echo "Shared Docker E2E image: \`$image\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
if: steps.plan.outputs.needs_e2e_image == '1'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Check existing shared Docker E2E images
id: image_exists
if: steps.plan.outputs.needs_e2e_image == '1'
shell: bash
env:
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }}
run: |
set -euo pipefail
bare_exists=0
functional_exists=0
needs_build=0
if [[ "${{ steps.plan.outputs.needs_bare_image }}" == "1" ]]; then
if docker manifest inspect "${{ steps.image.outputs.bare_image }}" >/dev/null 2>&1; then
bare_exists=1
echo "Shared Docker E2E bare image already exists: ${{ steps.image.outputs.bare_image }}"
elif [[ -n "$PROVIDED_BARE_IMAGE" ]]; then
echo "Provided bare Docker E2E image does not exist: $PROVIDED_BARE_IMAGE" >&2
exit 1
else
needs_build=1
fi
fi
if [[ "${{ steps.plan.outputs.needs_functional_image }}" == "1" ]]; then
if docker manifest inspect "${{ steps.image.outputs.functional_image }}" >/dev/null 2>&1; then
functional_exists=1
echo "Shared Docker E2E functional image already exists: ${{ steps.image.outputs.functional_image }}"
elif [[ -n "$PROVIDED_FUNCTIONAL_IMAGE" ]]; then
echo "Provided functional Docker E2E image does not exist: $PROVIDED_FUNCTIONAL_IMAGE" >&2
exit 1
else
needs_build=1
fi
fi
echo "bare_exists=$bare_exists" >> "$GITHUB_OUTPUT"
echo "functional_exists=$functional_exists" >> "$GITHUB_OUTPUT"
echo "needs_build=$needs_build" >> "$GITHUB_OUTPUT"
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
- name: Build and push shared Docker E2E image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./scripts/e2e/Dockerfile
target: bare
target: build
platforms: linux/amd64
tags: ${{ steps.image.outputs.bare_image }}
sbom: true
provenance: mode=max
push: true
- name: Build and push functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: functional
build-contexts: |
openclaw_package=.artifacts/docker-e2e-package
platforms: linux/amd64
tags: ${{ steps.image.outputs.functional_image }}
sbom: true
provenance: mode=max
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 && inputs.live_model_providers == ''
if: inputs.include_live_suites
runs-on: ubuntu-24.04
timeout-minutes: 75
strategy:
@@ -1101,163 +761,6 @@ jobs:
- name: Run Docker live model sweep
run: pnpm test:docker:live-models
validate_live_models_docker_targeted:
name: Docker live models (selected providers)
needs: validate_selected_ref
if: inputs.include_live_suites && inputs.live_model_providers != ''
runs-on: ubuntu-24.04
timeout-minutes: 75
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 }}
REQUESTED_LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_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: Normalize provider allowlist
shell: bash
run: |
set -euo pipefail
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
normalize_provider() {
local value="${1,,}"
case "$value" in
z.ai|z-ai) echo "zai" ;;
opencode|opencode-go) echo "opencode-go" ;;
open-router|openrouter) echo "openrouter" ;;
*) echo "$value" ;;
esac
}
is_known_provider() {
local value="$1"
local provider
for provider in "${all_providers[@]}"; do
[[ "$provider" == "$value" ]] && return 0
done
return 1
}
selected=()
declare -A seen=()
raw="${REQUESTED_LIVE_MODEL_PROVIDERS:-}"
normalized_all="${raw,,}"
normalized_all="${normalized_all//[[:space:],]/}"
if [[ -z "$normalized_all" || "$normalized_all" == "all" ]]; then
selected=("${all_providers[@]}")
else
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
provider="$(normalize_provider "$entry")"
if ! is_known_provider "$provider"; then
echo "Unknown live model provider '${entry}'. Expected one of: ${all_providers[*]}" >&2
exit 1
fi
if [[ -z "${seen[$provider]:-}" ]]; then
selected+=("$provider")
seen[$provider]=1
fi
done < <(printf '%s\n' "$raw" | tr ',' '\n' | tr '[:space:]' '\n')
fi
if [[ "${#selected[@]}" -eq 0 ]]; then
echo "No live model providers selected." >&2
exit 1
fi
providers_csv="$(IFS=,; echo "${selected[*]}")"
echo "OPENCLAW_LIVE_PROVIDERS=$providers_csv" >> "$GITHUB_ENV"
{
echo "Live model providers: \`$providers_csv\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Validate provider credentials
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
}
IFS=',' read -r -a providers <<<"${OPENCLAW_LIVE_PROVIDERS}"
for provider in "${providers[@]}"; do
case "$provider" 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: ${provider}" >&2
exit 1
;;
esac
done
- name: Run Docker live model sweep
run: pnpm test:docker:live-models
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag, or full commit SHA to validate
description: Existing release tag or current full 40-character workflow-branch commit SHA to validate (for example v2026.4.12 or 0123456789abcdef0123456789abcdef01234567)
required: true
type: string
provider:
@@ -63,8 +63,8 @@ jobs:
RELEASE_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_REF// }" ]] || [[ "${RELEASE_REF}" == -* ]]; then
echo "Expected a branch, tag, or full commit SHA; got: ${RELEASE_REF}" >&2
if [[ ! "${RELEASE_REF}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]] && [[ ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Expected an existing release tag or current full 40-character workflow-branch commit SHA, got: ${RELEASE_REF}" >&2
exit 1
fi
@@ -78,27 +78,24 @@ jobs:
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate selected ref belongs to this repository
- name: Validate selected ref is on workflow branch
env:
RELEASE_REF: ${{ inputs.ref }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
SELECTED_SHA="$(git rev-parse HEAD)"
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
if git tag --points-at "${SELECTED_SHA}" | grep -Eq '^v'; then
exit 0
RELEASE_BRANCH_REF="refs/remotes/origin/${WORKFLOW_REF_NAME}"
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
BRANCH_SHA="$(git rev-parse "${RELEASE_BRANCH_REF}")"
if [[ "$(git rev-parse HEAD)" != "${BRANCH_SHA}" ]]; then
echo "Commit SHA mode only supports the current ${WORKFLOW_REF_NAME} HEAD. Use a release tag for older commits." >&2
exit 1
fi
else
git merge-base --is-ancestor HEAD "${RELEASE_BRANCH_REF}"
fi
if git for-each-ref --format='%(refname:short)' --contains "${SELECTED_SHA}" refs/remotes/origin | grep -Eq '^origin/'; then
exit 0
fi
echo "Ref '${RELEASE_REF}' resolved to ${SELECTED_SHA}, but that commit is not reachable from an OpenClaw branch or release tag." >&2
echo "Secret-bearing release checks only run repository-owned branch/tag history, not arbitrary unreferenced commits." >&2
exit 1
- name: Capture selected inputs
id: inputs
env:
@@ -214,26 +211,6 @@ jobs:
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
package_acceptance_release_checks:
name: Run package acceptance
needs: [resolve_target]
permissions:
actions: read
contents: read
packages: write
pull-requests: read
uses: ./.github/workflows/package-acceptance.yml
with:
workflow_ref: ${{ github.ref_name }}
source: ref
package_ref: ${{ needs.resolve_target.outputs.ref }}
suite_profile: package
telegram_mode: mock-openai
secrets:
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 }}
qa_lab_parity_release_checks:
name: Run QA Lab parity gate
needs: [resolve_target]
@@ -355,7 +332,6 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS: "3000"
run: |
set -euo pipefail
@@ -368,9 +344,7 @@ jobs:
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--profile fast \
--fast \
--fail-fast
--fast
- name: Upload Matrix QA artifacts
if: always()
@@ -464,40 +438,3 @@ jobs:
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
summary:
name: Verify release checks
needs:
- install_smoke_release_checks
- cross_os_release_checks
- live_and_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify release check results
shell: bash
run: |
set -euo pipefail
failed=0
for item in \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
"cross_os_release_checks=${{ needs.cross_os_release_checks.result }}" \
"live_and_e2e_release_checks=${{ needs.live_and_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_release_checks=${{ needs.qa_lab_parity_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}"
do
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
echo "::error::${name} ended with ${result}"
failed=1
fi
done
exit "$failed"

View File

@@ -1,517 +0,0 @@
name: Package Acceptance
on:
workflow_dispatch:
inputs:
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: true
default: main
type: string
source:
description: Package candidate source
required: true
default: npm
type: choice
options:
- npm
- ref
- url
- artifact
package_ref:
description: Trusted package source ref when source=ref
required: true
default: main
type: string
package_spec:
description: Published package spec when source=npm
required: false
default: openclaw@beta
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
suite_profile:
description: Acceptance profile
required: true
default: package
type: choice
options:
- smoke
- package
- product
- full
- custom
docker_lanes:
description: Comma/space separated Docker lanes when suite_profile=custom
required: false
default: ""
type: string
telegram_mode:
description: Optional Telegram QA lane for the resolved package candidate
required: true
default: none
type: choice
options:
- none
- mock-openai
- live-frontier
workflow_call:
inputs:
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: false
default: main
type: string
source:
description: "Package candidate source: npm, ref, url, or artifact"
required: true
type: string
package_ref:
description: Trusted package source ref when source=ref
required: false
default: main
type: string
package_spec:
description: Published package spec when source=npm
required: false
default: openclaw@beta
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
suite_profile:
description: "Acceptance profile: smoke, package, product, full, or custom"
required: false
default: package
type: string
docker_lanes:
description: Comma/space separated Docker lanes when suite_profile=custom
required: false
default: ""
type: string
telegram_mode:
description: Optional Telegram QA lane for the resolved package candidate
required: false
default: none
type: string
secrets:
OPENAI_API_KEY:
required: false
OPENAI_BASE_URL:
required: false
ANTHROPIC_API_KEY:
required: false
ANTHROPIC_API_KEY_OLD:
required: false
ANTHROPIC_API_TOKEN:
required: false
BYTEPLUS_API_KEY:
required: false
CEREBRAS_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
required: false
KIMI_API_KEY:
required: false
MODELSTUDIO_API_KEY:
required: false
MOONSHOT_API_KEY:
required: false
MISTRAL_API_KEY:
required: false
MINIMAX_API_KEY:
required: false
OPENCODE_API_KEY:
required: false
OPENCODE_ZEN_API_KEY:
required: false
OPENCLAW_LIVE_BROWSER_CDP_URL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_MODEL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_VALUE:
required: false
GEMINI_API_KEY:
required: false
GOOGLE_API_KEY:
required: false
OPENROUTER_API_KEY:
required: false
QWEN_API_KEY:
required: false
FAL_KEY:
required: false
RUNWAY_API_KEY:
required: false
DEEPGRAM_API_KEY:
required: false
TOGETHER_API_KEY:
required: false
VYDRA_API_KEY:
required: false
XAI_API_KEY:
required: false
ZAI_API_KEY:
required: false
Z_AI_API_KEY:
required: false
BYTEPLUS_ACCESS_KEY_ID:
required: false
BYTEPLUS_SECRET_ACCESS_KEY:
required: false
CLAUDE_CODE_OAUTH_TOKEN:
required: false
OPENCLAW_CODEX_AUTH_JSON:
required: false
OPENCLAW_CODEX_CONFIG_TOML:
required: false
OPENCLAW_CLAUDE_JSON:
required: false
OPENCLAW_CLAUDE_CREDENTIALS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON:
required: false
OPENCLAW_GEMINI_SETTINGS_JSON:
required: false
FIREWORKS_API_KEY:
required: false
OPENCLAW_QA_CONVEX_SITE_URL:
required: false
OPENCLAW_QA_CONVEX_SECRET_CI:
required: false
permissions:
actions: read
contents: read
packages: write
pull-requests: read
concurrency:
group: package-acceptance-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
PACKAGE_ARTIFACT_NAME: package-under-test
jobs:
resolve_package:
name: Resolve package candidate
runs-on: ubuntu-24.04
timeout-minutes: 60
outputs:
docker_lanes: ${{ steps.profile.outputs.docker_lanes }}
include_live_suites: ${{ steps.profile.outputs.include_live_suites }}
include_openwebui: ${{ steps.profile.outputs.include_openwebui }}
include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }}
package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }}
package_sha256: ${{ steps.resolve.outputs.sha256 }}
package_version: ${{ steps.resolve.outputs.package_version }}
telegram_enabled: ${{ steps.profile.outputs.telegram_enabled }}
telegram_mode: ${{ steps.profile.outputs.telegram_mode }}
steps:
- name: Checkout package workflow ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
install-deps: "false"
- name: Download package artifact input
if: inputs.source == 'artifact'
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
ARTIFACT_NAME: ${{ inputs.artifact_name }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${ARTIFACT_RUN_ID// }" ]]; then
echo "artifact_run_id is required when source=artifact." >&2
exit 1
fi
if [[ -z "${ARTIFACT_NAME// }" ]]; then
echo "artifact_name is required when source=artifact." >&2
exit 1
fi
mkdir -p .artifacts/package-candidate-input
gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/package-candidate-input
- name: Resolve package candidate
id: resolve
env:
SOURCE: ${{ inputs.source }}
PACKAGE_REF: ${{ inputs.package_ref }}
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_URL: ${{ inputs.package_url }}
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
shell: bash
run: |
set -euo pipefail
artifact_dir=""
if [[ "$SOURCE" == "artifact" ]]; then
artifact_dir=".artifacts/package-candidate-input"
fi
node scripts/resolve-openclaw-package-candidate.mjs \
--source "$SOURCE" \
--package-ref "$PACKAGE_REF" \
--package-spec "$PACKAGE_SPEC" \
--package-url "$PACKAGE_URL" \
--package-sha256 "$PACKAGE_SHA256" \
--artifact-dir "${artifact_dir:-.}" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
- name: Select acceptance profile
id: profile
env:
SOURCE: ${{ inputs.source }}
SUITE_PROFILE: ${{ inputs.suite_profile }}
CUSTOM_DOCKER_LANES: ${{ inputs.docker_lanes }}
TELEGRAM_MODE: ${{ inputs.telegram_mode }}
shell: bash
run: |
set -euo pipefail
include_release_path_suites=false
include_openwebui=false
include_live_suites=false
docker_lanes=""
case "$SUITE_PROFILE" in
smoke)
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins-offline plugin-update"
;;
product)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)
include_release_path_suites=true
include_openwebui=true
;;
custom)
docker_lanes="$CUSTOM_DOCKER_LANES"
if [[ -z "${docker_lanes// }" ]]; then
echo "docker_lanes is required when suite_profile=custom." >&2
exit 1
fi
if [[ "$docker_lanes" == *"openwebui"* ]]; then
include_openwebui=true
fi
;;
*)
echo "Unknown suite_profile: $SUITE_PROFILE" >&2
exit 1
;;
esac
telegram_enabled=false
if [[ "$TELEGRAM_MODE" != "none" ]]; then
telegram_enabled=true
fi
{
echo "docker_lanes=$docker_lanes"
echo "include_release_path_suites=$include_release_path_suites"
echo "include_openwebui=$include_openwebui"
echo "include_live_suites=$include_live_suites"
echo "telegram_enabled=$telegram_enabled"
echo "telegram_mode=$TELEGRAM_MODE"
echo "package_artifact_name=${PACKAGE_ARTIFACT_NAME}"
} >> "$GITHUB_OUTPUT"
- name: Upload package-under-test artifact
uses: actions/upload-artifact@v7
with:
name: ${{ env.PACKAGE_ARTIFACT_NAME }}
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/package-candidate.json
retention-days: 14
if-no-files-found: error
- name: Summarize package candidate
env:
PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }}
PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }}
PACKAGE_REF: ${{ inputs.package_ref }}
SOURCE: ${{ inputs.source }}
SUITE_PROFILE: ${{ inputs.suite_profile }}
WORKFLOW_REF: ${{ inputs.workflow_ref }}
shell: bash
run: |
{
echo "## Package acceptance"
echo
echo "- Source: \`${SOURCE}\`"
echo "- Workflow ref: \`${WORKFLOW_REF}\`"
if [[ "${SOURCE}" == "ref" ]]; then
echo "- Package ref: \`${PACKAGE_REF}\`"
fi
echo "- Version: \`${PACKAGE_VERSION}\`"
echo "- SHA-256: \`${PACKAGE_SHA256}\`"
echo "- Profile: \`${SUITE_PROFILE}\`"
} >> "$GITHUB_STEP_SUMMARY"
docker_acceptance:
name: Docker product acceptance
needs: resolve_package
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ inputs.workflow_ref }}
include_repo_e2e: false
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
include_openwebui: ${{ needs.resolve_package.outputs.include_openwebui == 'true' }}
docker_lanes: ${{ needs.resolve_package.outputs.docker_lanes }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }}
live_models_only: false
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
package_telegram:
name: Telegram package acceptance
needs: resolve_package
if: needs.resolve_package.outputs.telegram_enabled == 'true'
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
with:
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }}
secrets:
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 }}
summary:
name: Verify package acceptance
needs: [resolve_package, docker_acceptance, package_telegram]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify package acceptance results
env:
DOCKER_RESULT: ${{ needs.docker_acceptance.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
shell: bash
run: |
set -euo pipefail
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
"docker_acceptance=${DOCKER_RESULT}" \
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
do
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
echo "::error::${name} ended with ${result}"
failed=1
fi
done
exit "$failed"

View File

@@ -18,19 +18,6 @@ on:
description: Optional comma-separated Discord scenario ids
required: false
type: string
matrix_profile:
description: Matrix QA profile for the live Matrix lane
required: false
default: all
type: choice
options:
- fast
- all
- transport
- media
- e2ee-smoke
- e2ee-deep
- e2ee-cli
permissions:
contents: read
@@ -212,7 +199,6 @@ jobs:
run_live_matrix:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
@@ -250,9 +236,7 @@ jobs:
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
INPUT_MATRIX_PROFILE: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile || 'fast' }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS: "3000"
run: |
set -euo pipefail
@@ -265,9 +249,7 @@ jobs:
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--profile "${INPUT_MATRIX_PROFILE}" \
--fast \
--fail-fast
--fast
- name: Upload Matrix QA artifacts
if: always()
@@ -278,83 +260,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
run_live_matrix_sharded:
name: Run Matrix live QA lane (${{ matrix.profile }})
needs: [authorize_actor, validate_selected_ref]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
strategy:
fail-fast: false
matrix:
profile:
- transport
- media
- e2ee-smoke
- e2ee-deep
- e2ee-cli
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 shard
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS: "3000"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/matrix-live-${{ matrix.profile }}-${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}" \
--profile "${{ matrix.profile }}" \
--fast \
--fail-fast
- name: Upload Matrix QA shard artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ matrix.profile }}-${{ 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]

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
@@ -41,7 +41,7 @@ jobs:
days-before-pr-close: 3
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -56,59 +56,11 @@ 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 #clawtributors 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,bad-barnacle
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 #clawtributors on Discord to talk to a maintainer.
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
@@ -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:
@@ -145,7 +97,7 @@ jobs:
days-before-pr-close: 3
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -160,57 +112,11 @@ 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 #clawtributors 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,bad-barnacle
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 #clawtributors on Discord to talk to a maintainer.
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:

View File

@@ -181,8 +181,7 @@ jobs:
- 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
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"

32
.gitignore vendored
View File

@@ -97,38 +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/openclaw-testing/
!.agents/skills/openclaw-testing/**
!.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

View File

@@ -29,7 +29,6 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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.
- Owner boundary: fix owner-specific behavior in the owner module. Shared/core gets generic seams only; no owner ids, dependency strings, defaults, migrations, or recovery policy. If a bug names an extension or its dependency, start in that extension and add a generic core seam only when multiple owners need it.
- 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.
- New seams: backwards-compatible, documented, versioned. Third-party plugins exist.
@@ -51,8 +50,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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`.
- Formatting: use `oxfmt`, not Prettier. Prefer `pnpm format:check` / `pnpm format`; for targeted files use `pnpm exec oxfmt --check --threads=1 <files...>` or `pnpm exec oxfmt --write --threads=1 <files...>`.
- Linting: use repo wrappers (`pnpm lint:*`, `scripts/run-oxlint.mjs`); do not invoke generic JS formatters/lints unless a repo script uses them.
- 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.
@@ -60,7 +58,6 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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.
- PR scan/triage: no unsolicited PR comments/reviews. Report in chat only unless explicitly asked, or a close/duplicate action needs a reason comment.
- 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`.
@@ -88,15 +85,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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.
- Before handoff/push: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`.
- 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.
@@ -120,7 +109,6 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
## Tests
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.4`.
- Avoid brittle tests that grep workflow/docs strings for operator policy. Prefer executable behavior, parsed config/schema checks, or live run proof; put release/CI policy reminders in AGENTS/docs instead.
- 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.
@@ -128,7 +116,6 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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.
- Do not run multiple independent `pnpm test`/Vitest commands concurrently in the same worktree. They can race on `node_modules/.experimental-vitest-cache` and fail with `ENOTEMPTY`. Use one grouped `pnpm test ...` invocation, run targeted lanes sequentially, or set distinct `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` values when true parallel Vitest processes are needed.
- 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`.
@@ -137,7 +124,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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` or `Thanks @codex`.
- 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.
## Git
@@ -145,9 +132,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- 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.
- `main`: no merge commits; rebase on latest `origin/main` before push.
- 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.

View File

@@ -4,176 +4,40 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.
### Fixes
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
- Plugins/install: stage bundled plugin runtime dependencies before Gateway startup and drain update restarts while preserving per-plugin isolation when pre-stage scan or install fails. Thanks @codex.
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
- Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc.
- Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.
- Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors.
- Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech.
- Cron: resolve failure alerts and failure-destination announcements against `session:<id>` targets before falling back to the creator session, so jobs created from group chats can notify the targeted direct session without cross-account routing errors. Refs #62777; carries forward #68535. Thanks @slideshow-dingo and @likewen-tech.
- Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui.
- Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.
- Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc.
- Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @1fanwang and @amknight.
- macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.
- Exec approvals: accept runtime-owned `source: "allow-always"` and `commandText` allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with `unexpected property 'source'`. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang.
- Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC.
- Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz.
- Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex.
- Memory/doctor: treat the specific `gateway timeout after ...` gateway memory probe result as inconclusive instead of reporting embeddings not ready, while preserving warnings for explicit failures. Fixes #44426; carries forward #46576 with the Greptile review feedback applied. Thanks Cengiz (@ghost).
- Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc.
- Gateway/startup: keep core request handlers, setup wizard, and channel runtime helpers off the boot path until the first matching request, wizard run, or channel start, reducing no-plugin Gateway ready RSS and avoidable startup imports. Thanks @vincentkoc.
- Gateway/startup: keep CLI outbound channel send dependencies as lazy request-time senders so Gateway boot no longer imports channel plugin registration just to construct default deps. Thanks @vincentkoc.
- Gateway/startup: split lightweight HTTP auth helpers away from model-override helpers so Gateway bind no longer imports model catalog selection while wiring base HTTP routes. Thanks @vincentkoc.
- Gateway/startup: lazy-load plugin HTTP route dispatch when active plugin routes exist so no-plugin Gateway boot skips plugin route runtime scope setup. Thanks @vincentkoc.
- CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc.
- Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc.
- Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq.
- Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.
- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.
- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim.
- Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.
- ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.
- Logging: propagate internal request trace scopes through Gateway HTTP requests and WebSocket frames so file logs, diagnostic events, agent run traces, model-call traces, OTEL spans, and trusted provider `traceparent` headers share a correlatable `traceId` without logging raw request or model content. Fixes #40353. Thanks @liangruochong44-ui.
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Providers/Ollama: read larger custom Modelfile `PARAMETER num_ctx` values from `/api/show` so auto-discovered Ollama models with expanded context no longer stay pinned to the base model context. Fixes #68344. Thanks @neeravmakwana.
- Providers/Ollama: honor configured model `params.num_ctx` in native and OpenAI-compatible Ollama requests so local models can cap runtime context without rebuilding Modelfiles. Fixes #44550 and #52206; supersedes #69464. Thanks @taitruong, @armi0024, and @LokiCode404.
- Providers/Ollama: forward whitelisted native Ollama model params such as `temperature`, `top_p`, and top-level `think` so users can disable API-level thinking or tune local models from config without proxy shims. Fixes #48010. Thanks @tangzhi, @pandego, @maweibin, @Adam-Researchh, and @EmpireCreator.
- Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n.
- Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys.
- Providers/Ollama: parse stringified native tool-call arguments before dispatch, preserving unsafe integer values so Ollama tool use receives structured parameters. Fixes #69735; supersedes #69910. Thanks @rongshuzhao and @yfge.
- Providers/Ollama: skip ambient localhost discovery unless Ollama auth or meaningful config opts in, preventing unexpected probes to `127.0.0.1:11434` for users who are not using Ollama. Fixes #56939; supersedes #57116. Thanks @IanxDev and @tsukhani.
- Providers/Ollama: skip implicit localhost discovery when a custom remote `api: "ollama"` provider is configured, while still treating `127/8` loopback hosts as local. Carries forward #43224. Thanks @issacthekaylon.
- Providers/models: honor provider-level `contextWindow`, `contextTokens`, and `maxTokens` as defaults when resolving discovered models, so local Ollama and other self-hosted providers can cap all models without repeating per-model entries. Fixes #44786; carries forward #44955. Thanks @voltwake and @maweibin.
- Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang.
- Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- Providers/Ollama: accept OpenAI SDK-style `baseURL` as an alias for `baseUrl` across discovery, streaming, setup pulls, embeddings, and web search so remote Ollama hosts are not silently ignored. Fixes #62533; supersedes #62549. Thanks @Julien-BKK and @Linux2010.
- Providers/Ollama: scope synthetic local auth and embedding bearer headers to declared Ollama host boundaries so cloud keys are not sent to local/self-hosted embedding endpoints and remote/cloud Ollama endpoints no longer receive the `ollama-local` marker as if it were a real token. Supersedes #69261 and #69857; refs #43945. Thanks @hyspacex, @maxramsay, and @Meli73.
- Providers/Ollama: resolve custom-named local Ollama providers such as `ollama-remote` through the Ollama synthetic-auth hook so subagents no longer miss `ollama-local` auth and silently fall back to cloud models. Fixes #43945. Thanks @Meli73 and @maxramsay.
- Providers/Ollama: add provider-scoped model request timeouts, thread them through guarded fetch connect/header/body/abort handling, and document `params.keep_alive` for cold local models so first-turn Ollama loads no longer require global agent timeout changes. Fixes #64541 and #68796; supersedes #65143 and #66511. Thanks @LittleJakub, @Juankcba, @uninhibite-scholar, and @yfge.
- Providers/Ollama: preserve explicit configured model input modalities when merging discovered provider metadata so custom vision models keep image support instead of silently dropping attachments. Fixes #39690; carries forward #39785. Thanks @Skrblik and @Mriris.
- Providers/Ollama: estimate native Ollama transcript usage when `/api/chat` omits prompt/eval counters while preserving exact zero counters, keeping local model runs visible in usage surfaces. Carries forward #39112. Thanks @TylonHH.
- Agents/Ollama: retry native Ollama turns that finish without user-visible text, including unsigned thinking-only responses, so constrained reasoning turns can continue instead of surfacing an empty reply. Carries forward #66552 and #61223. Thanks @yfge and @L3G.
- Docs/Ollama: expand setup recipes for local, LAN, cloud, multi-host, web search, embeddings, thinking control, and large-context troubleshooting. Thanks @codex.
- Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077.
- LLM Task/Ollama: accept model overrides that already include the selected provider prefix, avoiding doubled ids such as `ollama/ollama/llama3.2:latest`, and live-verify local Ollama JSON tasks return parsed output. Fixes #50052. Thanks @ralphy-maplebots and @Hollychou924.
- Memory/doctor: treat Ollama memory embeddings as key-optional so `openclaw doctor` no longer warns about a missing API key when the gateway reports embeddings are ready. Fixes #46584. Thanks @fengly78.
- Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81.
- Control UI/Ollama: show the resolved configured thinking default in chat and session thinking dropdowns so inherited `adaptive`/per-model thinking config no longer appears as `Default (off)` or a generic inherit value. Fixes #72407. Thanks @NotecAG.
- Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n.
- CLI/models: include explicitly configured provider models in `openclaw models list --provider <id>` without requiring the full catalog path, so configured Ollama models are visible. Fixes #65207. Thanks @drzeast-png.
- Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Gateway/session rows: report the same config-resolved thinking default that runtime sessions use, including global and per-agent defaults, so Control UI and TUI default labels stay aligned. (#71779, #70981, #71033, #70302) Thanks @chen-zhang-cs-code, @SymbolStar, and @cholaolu-boop.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
- Cron/context engine: run isolated cron jobs under run-scoped context-engine session keys so prior runs of the same job are not inherited unless the job is explicitly session-bound. (#72292) Thanks @jalehman.
- Control UI: localize command palette labels, categories, skill shortcuts, footer hints, and connect-command copy labels while preserving localized command palette search matching. (#61130, #61119) Thanks @rubensfox20.
- Plugins/memory-lancedb: request float embedding responses from OpenAI-compatible servers so local providers that default SDK requests to base64 no longer return dimension-mismatched LanceDB vectors while preserving configured dimensions. Fixes #45982. (#59048, #46069, #45986) Thanks @deep-introspection, @xiaokhkh, @caicongyang, and @thiswind.
- Plugins/memory-core: respect configured memory-search embedding concurrency during non-batch indexing so local Ollama embedding backends can serialize indexing instead of flooding the server. Fixes #66822. (#66931) Thanks @oliviareid-svg and @LyraInTheFlesh.
- Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.
- Gateway/models: repair legacy `models.providers.*.api = "openai"` config values to `openai-completions`, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.
## 2026.4.26
### Fixes
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
- Telegram: send a fresh final message for long-lived preview-streamed replies so the visible Telegram timestamp reflects completion time instead of the preview creation time. Thanks @rubencu.
## 2026.4.25
### Highlights
- Voice replies get a full TTS upgrade: `/tts latest`, chat-scoped auto-TTS controls, personas, per-agent/per-account overrides, and new Azure Speech, Xiaomi, Local CLI, Inworld, Volcengine, and ElevenLabs v3 provider coverage. Thanks @leonchui, @zoujiejun, @solar2ain, @cshape, @xuruiray, @itsuzef, and @barronlroth.
- Plugin startup and install paths move to the cold persisted registry, cutting broad manifest scans while making plugin update, repair, provider discovery, and install metadata more deterministic. Thanks @vincentkoc and @shakkernerd.
- OpenTelemetry coverage expands across model calls, token usage, tool loops, harness runs, exec processes, outbound delivery, context assembly, and memory pressure with bounded low-cardinality attributes. Thanks @vincentkoc, @jlapenna, @Lidang-Jiang, and @oc-factus.
- Browser automation gets safer tab URLs, iframe-aware role snapshots, CDP readiness tuning, headless one-shot launch, and deeper browser doctor probes for slow hosts. Thanks @beat843796 and @BenediktSchackenberg.
- Control UI and setup flows add PWA/Web Push support, Crestodian first-run repair, TUI setup, context mode selection, and a shorter startup greeting. Thanks @eduardocruz, @SebTardif, and @kevinlin-openai.
- Install/update hardening covers Windows, macOS, Linux, Docker, bundled plugin runtime deps, Node service restarts, LaunchAgent token rotation, and mixed-version gateway verification. Thanks @Kobevictor, @igormf, @abhinas90, @jsompis, @Solvely-Colin, and @gucasbrg.
## 2026.4.25 (Unreleased)
### Changes
- TTS/WhatsApp: add `/tts latest` read-aloud support with duplicate suppression and `/tts chat on|off|default` session-scoped auto-TTS overrides, completing the on-demand voice-note UX for current-chat replies. Fixes #66032.
- TTS/channels: resolve channel and account TTS overrides generically, enabling Feishu and QQBot accounts to deep-merge `channels.<channel>.accounts.<id>.tts` over global and per-agent TTS config. Thanks @sahilsatralkar.
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices, and make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active voice/provider override while keeping shared provider credentials and preferences in the existing TTS config surface.
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices while keeping shared provider credentials and preferences in the existing TTS config surface.
- TTS/agents: make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active `agents.list[].tts` voice/provider override.
- Providers/Azure Speech: add Azure Speech as a bundled TTS provider with Speech-resource auth, voice listing, SSML escaping, native Ogg/Opus voice-note output, and telephony output. (#51776) Thanks @leonchui.
- Google Meet: add calendar-backed attendance export workflows, export manifests, dry-run previews, and tool parity for meeting records.
- Control UI: add PWA install support and Web Push notifications for Gateway chat. (#44590) Thanks @eduardocruz.
- Browser automation: add safe tab URLs in agent responses plus a CDP-native role snapshot fallback with iframe-aware refs, cursor-clickable detection, target attach preparation, and `openclaw browser doctor --deep` live snapshot probing.
- CLI/image generation: expose generic `--background` on `openclaw infer image generate` and `openclaw infer image edit`, keep `--openai-background` as an OpenAI alias, and let fal image generation honor `--output-format png|jpeg`.
- CLI/image generation: expose generic `--background` on `openclaw infer image generate` and `openclaw infer image edit`, keep `--openai-background` as an OpenAI alias, and let fal image generation honor `--output-format png|jpeg`. Thanks @steipete.
- Browser/config: allow local managed Chrome launch discovery and post-launch CDP readiness timeouts to be raised for slower hosts such as Raspberry Pi. Fixes #66803. Thanks @beat843796.
- Discord: allow `channels.discord.voice.model` to override the LLM used for voice channel responses while keeping STT and TTS on their existing media settings. (#64368) Thanks @mrdavey.
- Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg.
- CLI/Crestodian/TUI: add the first-run setup helper, local planner fallback, full-TUI interactive Crestodian, startup progress indicators, context mode selector, and a shorter startup greeting. (#71720, #71760) Thanks @SebTardif and @kevinlin-openai.
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
- CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend.
- Plugins: migrate the local plugin registry automatically during package install/update, keeping install metadata in the plugin index while indexing existing plugin manifests for the new cold registry path. Thanks @vincentkoc and @shakkernerd.
- Plugins/doctor: make `openclaw doctor --fix` refresh the plugin index and cold registry index when needed without treating plugin install records as authored config. Thanks @vincentkoc and @shakkernerd.
- Plugins/hooks: add before-agent-finalize hooks, cron `jobId` hook context, bounded native permission fingerprints, and Codex MCP hook relay support. (#71765, #71758, #71707) Thanks @vincentkoc and @pashpashpash.
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
- Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc.
- Diagnostics/OTEL: support signal-specific OTLP endpoint overrides for traces, metrics, and logs via config or standard OTEL environment variables. Thanks @vincentkoc.
- Diagnostics/OTEL: emit bounded telemetry exporter health diagnostics for startup and log-export failures without exporting raw error text. Thanks @vincentkoc.
- Diagnostics/OTEL: export agent harness lifecycle telemetry as bounded `openclaw.harness.run` spans and `openclaw.harness.duration_ms` metrics so QA-lab, Codex, and future harnesses share one trace shape. Thanks @vincentkoc.
- Diagnostics/trace: propagate W3C `traceparent` headers from trusted model-call trace context to provider transports while replacing caller-supplied traceparent values. Thanks @vincentkoc.
- Diagnostics/Prometheus: add a bundled `diagnostics-prometheus` plugin with a protected gateway scrape route for low-cardinality diagnostics metrics. Thanks @vincentkoc.
- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc.
- Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc.
- Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc.
- Plugins/startup: normalize startup and provider plugin enablement through registry aliases so boot paths do not need the legacy manifest alias scan. Thanks @vincentkoc.
- Providers/plugins: resolve provider ownership, provider discovery scopes, and catalog-hook provider ids from the cold plugin registry instead of rescanning manifests on those paths. Thanks @vincentkoc.
- Plugins/registry: keep installed plugin index records focused on install/state/load paths and resolve plugin capabilities from manifests scoped to indexed plugins. Thanks @shakkernerd.
- Plugins/registry: route cold manifest and capability lookups through the installed plugin index so setup, channels, config, secrets, doctor, and provider metadata paths avoid broad plugin-root scans before runtime execution. Thanks @shakkernerd.
- CLI/models: speed up `models list --all --provider <id>` for static manifest-backed providers by loading catalog rows through the installed plugin index instead of broad manifest scans or runtime suppression hooks. Thanks @shakkernerd.
- CLI/models: use OpenClaw Provider Index preview rows as the final cold fallback for installable providers, while keeping user config, installed manifests, and refreshed cache rows above provider-index metadata. Thanks @vincentkoc.
- Providers/plugins: keep onboarding and auth-choice setup lists on cold manifest/install metadata and add Provider Index install metadata for not-yet-installed provider plugins. Thanks @vincentkoc.
- Providers/plugins: keep provider setup guidance and configure auth imports on cold manifest metadata, with a regression guard against static provider-runtime imports on setup/configure list paths. Thanks @vincentkoc.
- CLI/capabilities: keep capability command registration from importing the models auth runtime until `model auth login` actually runs. Thanks @vincentkoc.
- CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc.
- Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc.
- Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc.
- Plugins/compat: expand the central compatibility registry with dated owners, replacements, and maximum three-month removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc.
- Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc.
- Config/plugins: keep plugin command-alias validation on cold manifest metadata instead of importing the runtime alias resolver. Thanks @vincentkoc.
- Security/plugins: keep web-search credential presence checks on cold config, env, and manifest metadata instead of importing web-search provider runtime. Thanks @vincentkoc.
- Diagnostics/OTEL: surface provider request identifiers as bounded hashes on model-call diagnostics and span events, without exporting raw request IDs or metric labels. Thanks @Lidang-Jiang and @vincentkoc.
- Plugins/diagnostics: add metadata-only `model_call_started` and `model_call_ended` hooks for provider/model call telemetry without exposing prompts, responses, headers, request bodies, or raw provider request IDs. Thanks @vincentkoc.
- Diagnostics/OTEL: emit bounded context assembly diagnostics and export `openclaw.context.assembled` spans with prompt/history sizes but no prompt, history, response, or session-key content. Thanks @vincentkoc.
- Diagnostics/OTEL: export existing tool-loop diagnostics as `openclaw.tool.loop` counters and spans without loop messages, session identifiers, params, or tool output. Thanks @vincentkoc.
- Diagnostics/OTEL: export diagnostic memory samples and pressure as bounded memory histograms, counters, and pressure spans to help spot leak regressions without session or payload data. Thanks @vincentkoc.
- Diagnostics/OTEL: add the GenAI `gen_ai.client.token.usage` histogram for input/output model usage while keeping session identifiers and aggregate cache counters out of the semantic metric. Thanks @vincentkoc.
- Diagnostics/OTEL: add a bounded `openclaw.agent` label to OpenClaw token metrics so per-agent Grafana dashboards can group usage without exporting session identifiers. Thanks @oc-factus.
- Plugins/install: consolidate managed plugin install metadata into the state-managed plugin index at `plugins/installs.json`, replacing the temporary `plugins/installed-index.json` path and removing `plugins.installs` as an authored config surface. Thanks @vincentkoc and @shakkernerd.
- Diagnostics/OTEL: add the GenAI `gen_ai.client.operation.duration` histogram for model-call latency in seconds with bounded provider/model/API and error attributes. Thanks @vincentkoc.
- Diagnostics/OTEL: add GenAI usage token attributes to model-usage spans, including cache read/write input token counts without session identifiers or prompt/response content. Thanks @vincentkoc.
@@ -181,7 +45,6 @@ Docs: https://docs.openclaw.ai
- Diagnostics/OTEL: keep model-usage span GenAI provider attributes aligned with the existing semantic-convention opt-in policy, using legacy `gen_ai.system` unless latest experimental GenAI conventions are enabled. Thanks @vincentkoc.
- Diagnostics/OTEL: keep `gen_ai.request.model` present on GenAI token usage metrics with a bounded `unknown` fallback when model usage events do not include a model. Thanks @vincentkoc.
- Docs/OTEL: document the GenAI token and model-call duration metrics, model-usage span attributes, and `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` provider-attribute behavior. Thanks @vincentkoc.
- Docs: refresh the MCP, model provider, doctor, troubleshooting, BlueBubbles, media generation, TTS, subagents, skills, cron/tasks, exec approvals, and voice-call guides with structured Steps, Tabs, and Accordion content.
- Diagnostics/trace: add an internal traceparent propagation helper that only formats trusted dispatcher metadata, keeping plugin-emitted diagnostic traces out of outbound propagation by default. Thanks @vincentkoc.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
@@ -197,120 +60,32 @@ Docs: https://docs.openclaw.ai
- Codex harness: require Codex app-server `0.125.0` or newer and cover native MCP `PreToolUse`, `PostToolUse`, and `PermissionRequest` payloads through the OpenClaw hook relay.
- Agents/Codex: teach prompts and `agents_list` to surface native Codex app-server availability so agents prefer `/codex ...` over Codex ACP unless ACP/acpx is explicit. Thanks @vincentkoc.
- ACPX/Droid: add Factory Droid to the live ACP bind Docker matrix, including `.factory` settings staging, `FACTORY_API_KEY` forwarding, and the single-agent `test:docker:live-acp-bind:droid` recipe.
- TTS/personas: add provider-aware TTS personas with deterministic provider binding merges, `/tts persona` controls, gateway/CLI persona state, Google Gemini `audio-profile-v1` prompt wrapping, and OpenAI instruction mapping. (#70748) Thanks @barronlroth.
- Voice Wake: add trigger-based routing so macOS voice wake phrases can select a configured agent or session target, with Gateway routing APIs and node update events. (#30354) Thanks @longbiaochen.
### Fixes
- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc.
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
- Agents/runtime: add `agentRuntime.id` as the canonical config key, migrate legacy runtime-policy configs with `openclaw doctor --fix`, route canonical Anthropic models through `claude-cli` without passing CLI backend aliases to embedded harness selection, and load CLI backend owner plugins before channel startup. Fixes #71957. Thanks @WolvenRA.
- CLI/update: guard Windows scheduled-task stops by state and timeout so auto-update restart cannot hang indefinitely on `schtasks /End` before stale-listener cleanup. Fixes #69970. Thanks @yangswld and @sherlock-huang.
- Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf.
- Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex.
- Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault.
- CLI/update: fail package updates when post-update plugin sync fails and refresh legacy npm plugin install records before trusting unchanged artifacts, preventing successful updates from restarting with stale or failed plugin state. Thanks @vincentkoc and @shakkernerd.
- Release/update: reject pre-populated bundled plugin `.openclaw-install-stage` directories, including mixed-case path variants, before package inventory generation so release tarballs cannot ship poisoned runtime-dependency staging debris. Fixes #71752. Thanks @hclsys.
- Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28.
- Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008.
- Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek.
- Plugins/channels: keep security checks, thread-binding placement, provider summaries, health formatting, and message action labels on read-only or already-loaded channel metadata instead of importing full channel runtime. Thanks @shakkernerd.
- Plugins/status: keep config-only channel labels and status security summaries from importing plugin runtime modules just to render metadata. Thanks @shakkernerd.
- Sessions/channels: stop group-session metadata from loading bundled channel runtime just to classify `#channel` subjects, using only already-loaded channel capabilities on that path. Thanks @shakkernerd.
- Plugins/channels: keep native command and native skill `auto` defaults on static channel metadata so config, audit, and command-list checks do not load channel runtime just to read those defaults. Thanks @shakkernerd.
- CLI/channels: keep channel remove selection and all-channel capabilities summaries on read-only plugin metadata, loading channel runtime only for the selected mutation path. Thanks @shakkernerd.
- CLI/models: keep Provider Index preview rows out of `models list --all --provider <id>` when the owning provider plugin is disabled, preserving config authority for cold catalog fallbacks. Thanks @shakkernerd.
- CLI/model runs: keep `openclaw infer model run` on explicit OpenRouter models from loading the full provider catalog or inheriting chat-agent silent-reply policy, restoring non-empty one-shot probe output. Fixes #68791. Thanks @limpredator.
- Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io.
- Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj.
- Installer/Windows: route PowerShell install failures through a top-level handler so `iwr ... | iex` returns control to the current shell while direct script-file runs still exit non-zero. Fixes #38054. Thanks @PwrSrg.
- CLI/Volta: respawn raw `openclaw` CLI runs through the named `node` shim when the current Node executable resolves to `volta-shim`, avoiding direct shim execution failures in non-interactive shells. Fixes #68672. Thanks @sanchezm86.
- Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio.
- Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963.
- Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg.
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.
- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc.
- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc.
- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc.
- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc.
- Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker.
- Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai.
- Node/Linux: make `openclaw node install` enable and restart the `openclaw-node` systemd unit instead of the gateway unit on node-only VMs. Fixes #68287. Thanks @dlebee-agent.
- Browser/CDP: retry transient raw-CDP WebSocket handshake failures before any browser command is sent, and reconnect stale persistent Playwright CDP sessions for safe tab-list reads without replaying mutating browser actions. Fixes #67728.
- Gateway/Linux: retry `systemctl --user enable` after a second daemon reload when the freshly written gateway unit is not visible yet on migrated systemd installs. Fixes #65184. Thanks @liushuaiiu.
- Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu.
- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd.
- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd.
- Plugins/chat: keep `/plugins list`, `/plugins enable`, and `/plugins disable` on the persisted plugin index path so chat plugin management does not load diagnostic/runtime plugin registries before execution. Thanks @shakkernerd.
- Plugins/doctor: read workspace plugin status and legacy web-search ownership through installed-index manifest metadata instead of broad manifest registry scans. Thanks @shakkernerd.
- CLI/agents: read channel provider status from read-only plugin index metadata for text `agents list` output instead of the loaded channel registry. Thanks @shakkernerd.
- Logging: redact configured secret patterns at console and file-log sink exits so credentials that reach the logger are masked before terminal display or JSONL persistence. Fixes #67953. Thanks @Ziy1-Tan.
- Gateway/services: refuse process and service mutations from an older OpenClaw binary when the config was last written by a newer version, preventing split-brain installs from stopping or rewriting newer gateway services. Fixes #57079.
- Gateway: reserve `/healthz` and `/readyz` ahead of plugin, canvas, and Control UI HTTP stages so liveness/readiness probes still answer when a later route handler stalls. Fixes #69674. Thanks @Xike-Creek.
- Logging: load `logging.file` and redaction settings directly from the active OpenClaw config path in bundled runtimes, so packaged gateways stop falling back to `/tmp/openclaw`. Fixes #59370, #67168, and #61295. Thanks @KeaneYan, @Pan9hu, and @zsjlovelike.
- Logging: rotate file logs at `logging.maxFileBytes`, keep bounded numbered archives, and make long-lived rolling loggers follow the current-day file instead of suppressing diagnostics or writing stale dated files. Fixes #58583 and #62381. Thanks @jpeghead and @zhaoleink.
- Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI.
- macOS/Node: keep native remote app nodes from advertising `browser.proxy`, start browser-capable CLI node services through the restored `openclaw node start` command, and show an actionable browser-control error when the local control service is missing. Fixes #66637.
- Gateway/update: fail package updates when the restarted managed gateway reports the wrong version, including fallback restarts and JSON mode, avoiding false-success mixed-version restarts after macOS LaunchAgent updates. Fixes #71835. Thanks @abhinas90 and @jsompis.
- Gateway/update: warn before package updates and bundled plugin runtime-dependency repairs when the target volume appears low on disk space, without blocking installs on best-effort filesystem checks. Fixes #71835. Thanks @abhinas90 and @jsompis.
- Plugins/runtime deps: surface activated plugin load failures in health and fail package-update restart verification or doctor repair when bundled runtime deps still cannot load, avoiding false-success repairs. (#71883) Thanks @Solvely-Colin.
- Gateway/Linux: include fnm `aliases/default/bin` in generated service PATHs and let doctor accept either modern fnm aliases or the legacy `current/bin` symlink, avoiding false PATH repair prompts. Fixes #68169. Thanks @richard-scott.
- Installer/Linux: run apt installs with noninteractive dpkg and needrestart settings so fresh Ubuntu 24.04 `curl | bash` installs do not hang while installing Node.js, Git, or build tools. Fixes #41146. Thanks @iht76, @alexcarv318, @cs3gallery, @firofame, and @cgdusek.
- Providers/Bedrock: defer the AWS SDK import until Bedrock discovery actually runs so plugin registration and setup stay lightweight on cold start. Fixes #71690. Thanks @jarvis-ai-gregmoser.
- Installer/macOS: stop immediately when Homebrew `node@24` installation fails and avoid printing PATH advice for missing Homebrew Node installs. Fixes #70411. Thanks @1fanwang.
- WhatsApp: remove ack reactions after a visible reply when `messages.removeAckAfterReply` is enabled, matching other reaction-capable channels. Fixes #26183. Thanks @MrUnforsaken.
- Providers/Z.AI: map OpenClaw thinking controls to Z.AI's `thinking` payload and add opt-in preserved thinking replay via `params.preserveThinking`, so GLM 5.x can keep prior `reasoning_content` when requested. Fixes #58680. Thanks @xuanmingguo.
- Channels/status: keep read-only channel lists on manifest and package metadata by default, loading setup runtime only for explicit fallback callers. Thanks @shakkernerd.
- Plugins: scope setup and web-provider metadata manifest reads to explicit plugin ids when callers already know the owning plugin set. Thanks @vincentkoc.
- Plugins/onboarding: defer onboarding install-record index writes until the guarded config commit so setup failures cannot leave the plugin index ahead of `openclaw.json`. Thanks @shakkernerd.
- Plugins/registry: resolve web provider ownership from the installed plugin index instead of broad manifest scans on secret, tool, and pricing paths. Thanks @shakkernerd.
- Config/providers: accept `video` and `audio` in configured model `input` values and preserve them in provider catalog entries. Fixes #20721. Thanks @alvinttang.
- Models/auth: honor the parent `--agent` flag for auth write commands (`add`, `login`, `setup-token`, `paste-token`, and the GitHub Copilot shortcut) so OAuth/API-key/token results are written to the requested agent store instead of the default agent. Fixes #71864. (#71933) Thanks @balric-seo.
- TTS: strip model-emitted TTS directives from streamed block text before channel delivery, including directives split across adjacent blocks, while preserving the accumulated raw reply for final-mode synthesis. Fixes #38937.
- TTS: keep explicit `provider=...` directive keys scoped to that provider and warn on unsupported keys instead of letting another speech provider consume overlapping keys. Fixes #60131.
- TTS/Feishu: normalize final-mode streamed TTS-only audio before delivery so generated voice-note files use the same safe media path and native voice routing as normal final replies. Fixes #71920.
- Feishu: transcribe inbound voice-note audio with the shared media audio path before agent dispatch and keep raw Feishu `file_key` payloads out of message text. Fixes #67120 and #61876.
- Tasks: terminalize async Gateway agent task records from the Gateway run result while preserving aborted, failed, and cancelled outcomes instead of leaving completed runs stuck as active or lost. (#71905) Thanks @likewen-tech.
- WhatsApp: let authorized group voice-note transcripts satisfy mention gating before reply dispatch, while keeping unmentioned transcripts in pending group history. Fixes #44908.
- Media understanding: carry channel voice-note preflight state into attachment selection so WhatsApp, Feishu, Telegram, and Discord do not transcribe the same inbound audio twice. Fixes #70580.
- TTS/BlueBubbles: deliver compatible auto-TTS audio as iMessage voice memo bubbles instead of plain MP3/CAF file attachments. Fixes #16848.
- TTS: resolve voice-note and voice-memo routing from channel plugin capabilities instead of speech-core-owned channel id lists.
- ACP: send subagent and async-task completion wakes to external ACP harnesses as plain prompts instead of OpenClaw internal runtime-context envelopes, while keeping those envelopes out of ACP transcripts.
- TTS/status: show configured TTS model, voice, and sanitized custom endpoint in `/status`, preserve OpenAI-compatible TTS instructions on custom endpoints, and retry empty Microsoft/Edge TTS output once. Addresses #46602, #47232, and #43936. Thanks @leekuangtao, @Huntterxx, and @rex993.
- Agents/Gateway: steer agent-driven config edits and restarts through the owner-only `gateway` tool, document `config.schema.lookup` as the field-doc source, and warn against using `gateway stop && gateway start` as a restart substitute on macOS. Fixes #71929. Thanks @ygc3817922006-sketch.
- Media understanding/audio: inject a deterministic transcript placeholder for too-small voice notes so agents do not hallucinate transcription or provider failures. Fixes #48944. Thanks @eulicesl.
- Providers/vLLM: send Nemotron 3 chat-template kwargs when thinking is off and honor configured `params.chat_template_kwargs` for OpenAI-compatible completions, so vLLM/Nemotron replies stay visible instead of becoming thinking-only. Fixes #71891. Thanks @jmystaki-create and @dennis-lynch.
- Channels/replies: strip copied inbound metadata blocks from user-facing assistant replies and model replay history, so Discord/vLLM sessions do not leak `Conversation info` / `UNTRUSTED ... message body` envelopes after a model echoes them. Fixes #71847. Thanks @jmystaki-create.
- Subagents/memory: keep inter-session completion wakes out of memory and dreaming session exports, and strip internal runtime-context blocks from realtime Control UI chat events.
- Agents/Claude: treat zero-token empty `stop` turns as failed provider output, retry once, repair replay, and allow configured model fallback instead of preserving them as successful silent replies. Fixes #71880. Thanks @MagnaAI.
- Tasks: normalize task lifecycle timestamps at create, update, and restore time, and report retained lost tasks as audit warnings until their cleanup window expires. (#71871) Thanks @likewen-tech.
- ACP: send subagent and async-task completion wakes to external ACP harnesses as
plain prompts instead of OpenClaw internal runtime-context envelopes, while
keeping those envelopes out of ACP transcripts.
- Agents/Claude: treat zero-token empty `stop` turns as failed provider output,
retry once, repair replay, and allow configured model fallback instead of
preserving them as successful silent replies. Fixes #71880. Thanks @MagnaAI.
- Diagnostics/OTEL: treat normal early model stream cleanup as a completed model call instead of exporting a misleading `StreamAbandoned` error span. Thanks @vincentkoc.
- Gateway/pairing: stop corrupt or unreadable device/node pairing stores from being treated as empty state, preserving `paired.json` for repair instead of overwriting approved pairings. Fixes #71873. Thanks @iret77.
- ACP: keep `/acp` management commands, plus local `/status` and `/unfocus`, on the Gateway path inside ACP-bound threads so they are not consumed as ACP prompt text. Fixes #66298. Thanks @kindomLee.
- ACPX: stop probing ACP agents during normal Gateway startup; the embedded backend now registers without spawning Codex/ACP child processes unless `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1` is explicitly set.
- CLI/image edit: accept `--size`, `--aspect-ratio`, and `--resolution` on `openclaw infer image edit` and report all supported edit flags from `capability inspect image.edit`. Thanks @Pinghuachiu.
- ACP: wait for the configured runtime backend to become healthy before startup identity reconciliation, avoiding transient acpx warnings during Gateway boot. Fixes #40566.
- Channels/ACP bindings: time out configured binding readiness checks instead of letting Discord preflight hang forever when an ACP target never settles. Fixes #68776.
- Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks @WolvenRA.
- Control UI: keep locally optimistic chat messages visible when a history reload temporarily returns empty, avoiding lost first-turn messages on high-latency gateways. Fixes #71878. Thanks @WolvenRA.
- Control UI: keep chat history limits based on visible messages after filtering heartbeat and control-only transcript rows, so recent hidden entries no longer make older visible replies disappear. Thanks @WolvenRA.
- Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`, and `media://inbound/...` markers from pruned model replay context so stale media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks @jmeadlock.
- Docker/Bonjour: disable Bonjour/mDNS advertising by default for bundled Compose gateways on bridge networking, while keeping host/macvlan opt-in with `OPENCLAW_DISABLE_BONJOUR=0`. Fixes #71879. Thanks @gbballpack.
- CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. Thanks @pesvobodak.
- Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`,
and `media://inbound/...` markers from pruned model replay context so stale
media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks
@jmeadlock.
- CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure`
and show daemon state separately when available, so `gateway.tailscale.mode:
"off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790.
Thanks @pesvobodak.
- Plugins/Bonjour: stop ciao mDNS watchdog failures from looping forever when the advertiser stays stuck in `probing` or `announcing`; Bonjour now disables itself for the current Gateway process after repeated failed restarts while the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux, @FiredMosquito831, and @spikefcz.
- Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823.
- macOS/remote SSH: keep discovered gateway hosts in `gateway.remote.sshTarget` while pinning SSH transport URLs to the local loopback tunnel, so browser automation does not regress into blocked non-loopback `ws://` endpoints. Fixes #67336.
- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv.
- Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin.
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex.
- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402.
- Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798.
- Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.
@@ -319,15 +94,11 @@ Docs: https://docs.openclaw.ai
- Browser automation: keep stable tab ids and labels attached when Chromium replaces the raw target after form submissions or other action-triggered navigations, and return the replacement `targetId` from `/act` when the match is provable. Fixes #46137.
- QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs directly for owner-authorized senders instead of returning `cronParams` and relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) Thanks @GaosCode.
- Agents/ACP: hide `sessions_spawn` ACP runtime options unless an ACP backend is loaded, and make `/acp doctor` call out `plugins.allow` blocking bundled `acpx`. Thanks @vincentkoc.
- Agents/Codex: keep ACP prompt/skill routing hidden unless an ACP runtime backend is available, and warn in doctor when enabled Codex plugin configs still route `openai-codex/*` models through PI. Thanks @vincentkoc.
- Media delivery: avoid sending generated image attachments twice when the assistant reply already includes explicit `MEDIA:` lines for the same turn, and reject unsafe remote `MEDIA:` URLs before delivery. Thanks @pashpashpash.
- Codex harness: ignore retryable app-server error notifications after Codex recovers, and preserve the real nested error message for terminal app-server failures instead of replacing it with a generic failure. Thanks @pashpashpash.
- Agents/Codex: prepare native Codex sub-agent session metadata without a nested Gateway session patch and add a focused Docker smoke for the app-server sub-agent path. Thanks @vincentkoc.
- Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan.
- Image understanding: preserve configured provider-prefixed vision model metadata when callers request the model without the provider prefix, so custom image models keep their `input: ["text", "image"]` capability. Fixes #33185. Thanks @Kobe9312 and @vincentkoc.
- Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd.
- Plugins/install: reject native plugin archives that do not include a valid `openclaw.plugin.json`, preventing manifestless archives from writing install records that later show missing-manifest diagnostics. Thanks @shakkernerd.
- Plugins/uninstall: remove tracked managed plugin install directories even when the persisted install path differs from the default id-derived target, while still refusing deletes outside the managed extensions root. Thanks @shakkernerd.
- Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd.
- Plugins/onboarding: defer channel/provider plugin install records until the owning config write commits, keeping setup failures from advancing the plugin index ahead of `openclaw.json`. Thanks @shakkernerd.
- Plugins/config: route configure and agent setup writes with pending plugin install records through the plugin index commit helper so provider onboarding metadata is not stripped by plain config writes. Thanks @shakkernerd.
@@ -336,23 +107,21 @@ Docs: https://docs.openclaw.ai
- Sessions: keep embedded runtime context out of the visible user prompt by sending it as a hidden next-turn custom message, and teach doctor to repair affected 2026.4.24 transcripts with duplicated prompt-rewrite branches. Fixes #71761.
- Gateway/subagents: keep direct-loopback backend RPCs authenticated with the shared gateway token/password off stale CLI paired-device scope baselines, so internal calls no longer hit `scope-upgrade` pairing prompts while remote, browser, node, device-token, and explicit-device paths still require normal pairing approval. Fixes #63548.
- Providers/Azure OpenAI: give deployment-scoped image generation requests a longer 600s default timeout so slow `gpt-image-2` generations can complete without a per-call `timeoutMs`. Fixes #71705. Thanks @voytas75.
- Gateway/plugins: link source-checkout bundled runtime dependency caches instead of recursively copying `node_modules` on the gateway main thread, preventing local status, node, and skill probes from timing out during startup cache restores.
- Skills/remote nodes: only expose remote macOS skill bins for connected nodes, clear stale bin matches when node probes fail, and include probe command, timeout, bin count, and connection state in timeout logs.
- Skills/remote nodes: recognize `system.which` object-map responses when probing connected macOS nodes, so Linux gateways can expose macOS-only skills such as Apple Notes when the required binaries are installed remotely. Fixes #71877. Thanks @miguelarios.
- Gateway/plugins: link source-checkout bundled runtime dependency caches instead of recursively copying `node_modules` on the gateway main thread, preventing local status, node, and skill probes from timing out during startup cache restores. Thanks @steipete.
- Skills/remote nodes: only expose remote macOS skill bins for connected nodes, clear stale bin matches when node probes fail, and include probe command, timeout, bin count, and connection state in timeout logs. Thanks @steipete.
- CLI/gateway: keep diagnostic probes from creating first-time read-only device pairings, while still reusing cached device tokens for detailed read probes. Fixes #71766. Thanks @SunboZ.
- CLI/plugins: keep `message` startup, `channels logs`, `agents delete`, and `agents set-identity` off broad plugin preloading; message delivery still loads plugins when the action actually runs.
- Image understanding: resolve configured image models such as local LM Studio vision entries before reporting `Unknown model` when the discovery registry has not registered that provider. Fixes #66486. Thanks @zhanggpcsu.
- QQ Bot: ignore self-echoed bot messages using the outbound ref-index marker, preventing mirrored replies from re-entering the agent loop while still allowing users to quote bot replies. Fixes #71912. Thanks @wangyc6003.
- Sessions: separate reset freshness from session-store `updatedAt`, so heartbeat, cron, exec, and gateway bookkeeping no longer prevent configured daily/idle resets from rolling long-running channel sessions. Fixes #68315, #63732, #63820, and #69083. Thanks @maxatv, @longhairedsi, @bradfreels, and @akessel56.
- Sessions: clear queued system-event notices during `/new`, `/reset`, gateway `sessions.reset`, and daily/idle rollover so stale background updates cannot leak into the first prompt of the fresh session. Fixes #66864. Thanks @opeyio, @Magicray1217, and @cedillarack.
- CLI/agents: keep `agents bind`, `agents unbind`, and `agents bindings` on setup-safe channel metadata paths so they do not preload bundled plugin runtimes or stage runtime dependencies. Fixes #71743.
- Plugins/registry: preserve explicit disabled plugin records during registry migration without persisting every unused bundled plugin discovered on disk. Thanks @shakkernerd.
- Windows/native: keep CLI startup and bundled provider plugin loading off Windows ESM raw-path failure paths, fixing native onboarding/install smoke on Node 24.
- Windows/native: keep CLI startup and bundled provider plugin loading off Windows ESM raw-path failure paths, fixing native onboarding/install smoke on Node 24. Thanks @steipete.
- Plugins/doctor: read bundled channel doctor capabilities through the same packaged plugin directory resolver used by plugin loading, so published installs keep Matrix DM allowlist repairs on `channels.matrix.dm.*` instead of writing invalid top-level `dmPolicy` keys. Fixes #71757.
- Plugins/Windows: keep bundled plugin Jiti loaders off the native import path on Windows so channel plugins such as Telegram no longer crash with `ERR_UNSUPPORTED_ESM_URL_SCHEME` on `C:\...` paths. Fixes #71749. Thanks @smeyer9.
- Providers/Ollama: use Ollama's current `/api/web_search` endpoint and honor `https://ollama.com` model-provider base URLs for Ollama Web Search. Fixes #71741. Thanks @madhvidua.
- Memory/Ollama: serialize Ollama memory embedding batches and add an inline batch timeout override, with longer defaults for local/self-hosted embedding providers.
- Sessions/usage: exclude compaction checkpoint transcript snapshots from usage totals and session discovery, while keeping old checkpoint files removable.
- Memory/Ollama: serialize Ollama memory embedding batches and add an inline batch timeout override, with longer defaults for local/self-hosted embedding providers. Thanks @steipete.
- Sessions/usage: exclude compaction checkpoint transcript snapshots from usage totals and session discovery, while keeping old checkpoint files removable. Thanks @steipete.
- CLI/agents: keep `openclaw agents list --json` on the config-only path by default, avoiding bundled plugin loading unless callers request `--bindings`. Fixes #71739. Thanks @kaloster.
- Plugins/install: force plugin dependency installs to stay project-local even when inherited npm config requests global installs, so successful installs still materialize the plugin's staged `node_modules`.
- Providers/Google: transcode Gemini TTS PCM to Opus for voice-note targets so WhatsApp and other native voice-note replies can play as voice messages.
@@ -366,22 +135,21 @@ Docs: https://docs.openclaw.ai
- ACP/oneshot: reconcile runtime session identity before closing completed oneshot ACP runs, so finished `sessions.json` entries do not stay stuck with `acp.identity.state="pending"`.
- ACPX: bundle `acpx@0.6.1` so unsupported generic model overrides fail clearly instead of silently falling back to the target adapter default.
- ACP/models: document that non-Codex ACP model overrides require adapter support for ACP `models` plus `session/set_model`, so unsupported harnesses fail clearly instead of silently falling back to their defaults.
- Plugins/Voice Call: treat missing provider credentials as setup-incomplete during Gateway startup and log the missing keys as a warning instead of a runtime startup error, while keeping explicit command/tool errors when used.
- Plugins/Voice Call: treat missing provider credentials as setup-incomplete during Gateway startup and log the missing keys as a warning instead of a runtime startup error, while keeping explicit command/tool errors when used. Thanks @steipete.
- Android/Talk Mode: prevent duplicate TTS playback when fast or repeated final chat events arrive while Talk Mode is waiting for its own response. Fixes #46546.
- Tooling/check:changed: pass parent heavy-check lock markers to lint lanes so `pnpm check:changed` no longer waits on its own `lint:extensions` child.
- Tooling/check:changed: pass parent heavy-check lock markers to lint lanes so `pnpm check:changed` no longer waits on its own `lint:extensions` child. Thanks @steipete.
- CLI/completion: dedupe provider auth flags before registering `openclaw onboard` options, so completion-cache refresh during update no longer fails when stale core fallback flags overlap plugin manifest flags. Fixes #71667.
- Diagnostics/trace: report live context usage from the current prompt snapshot instead of provider turn totals, avoiding false near-full context spikes on cached or tool-heavy runs.
- Providers/Google: honor `models.providers.google.request.allowPrivateNetwork` for Gemini TTS and telephony TTS, matching Google image generation and media understanding. (#71723) Thanks @ro-hansolo.
- Providers/MiniMax: register `minimax-portal` for music and video generation, preserving OAuth auth and regional MiniMax base URLs across the shared `music_generate` and `video_generate` tools. (#63241) Thanks @tars90percent.
- Providers/onboarding: keep Runway and Alibaba Model Studio out of the text-inference setup picker by scoping their video-generation auth choices to the media setup flow. (#65856) Thanks @Jah-yee.
- Plugins/Bonjour: stop the gateway from crash-looping on `CIAO PROBING CANCELLED` when the mDNS watchdog cancels a stuck probe. Restores the rejection-handler wiring dropped during the bonjour plugin migration and shares unhandled-rejection state across module instances so plugin-staged copies of `openclaw/plugin-sdk/runtime` register into the same handler set the host consults. Especially affects Docker on macOS, where mDNS probing reliably hits the watchdog. Thanks @troyhitch.
- Google Meet: report pinned Chrome nodes as offline or missing capabilities in setup/join diagnostics, keep inaccessible nodes out of auto-selection, and preflight local BlackHole/SoX requirements before agents try local Chrome.
- Google Meet: report pinned Chrome nodes as offline or missing capabilities in setup/join diagnostics, keep inaccessible nodes out of auto-selection, and preflight local BlackHole/SoX requirements before agents try local Chrome. Thanks @steipete.
- Providers/MiniMax: route `image-01` requests to the dedicated image generation endpoint while preserving CN endpoint selection. Fixes #61149. Thanks @mushuiyu886.
- Plugins/startup: remove ownerless bundled runtime-dependency install locks after a short grace window and include lock owner details when startup times out waiting for a plugin runtime-deps lock.
- Plugins/install: anchor bundled runtime-dependency npm installs with an OpenClaw-owned package manifest so Linux updates cannot accidentally write to a parent `$HOME/node_modules` tree. Fixes #71730.
- Plugins/install: pass onboarding plugin config into plugin index writes so local plugin installs outside default discovery roots keep their install records. Thanks @shakkernerd.
- Plugins/install: migrate shipped `plugins.installs` config records into the plugin index while stripping them from runtime config and future writes. Thanks @shakkernerd.
- Plugins/install: durably remove shipped `plugins.installs` from `openclaw.json` after its records are copied into the plugin index, while rolling back the index write if config cleanup fails. Thanks @shakkernerd.
- Plugins/install: keep migrated plugin install records in the plugin index even when the plugin manifest is missing or invalid, so update, uninstall, inspect, and audit can still recover broken installs. Thanks @shakkernerd.
- Plugins/security: keep plugin audit JSON check ids stable while reporting plugin index install-record findings with updated wording. Thanks @shakkernerd.
- CLI/config: reject direct `plugins.installs` edits with guidance to use `openclaw plugins install`, `openclaw plugins update`, or `openclaw plugins uninstall` instead. Thanks @shakkernerd.
@@ -432,7 +200,6 @@ Docs: https://docs.openclaw.ai
- CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request.
- Feishu: keep streaming cards to one live card per turn, flush throttled card edits after meaningful text boundaries, and skip exact block/partial repeats so tool-heavy replies do not duplicate card output. Thanks @allan0509.
- Feishu: finish the streaming-card duplicate closeout by stripping leaked reasoning tags, preserving cross-block partial snapshots, enabling topic-thread streaming cards, omitting the generic `main` card header, surfacing transient tool/compaction status, and cleaning streaming state after close failures. Thanks @sesame437, @Vicky-v7, @maoku-family, @Pengxiao-Wang, and @Maple778.
- Telegram: keep final-only answers on the normal final-send path instead of creating synthetic draft previews, while preserving real partial preview finalization. Credited from #39213. Thanks @chalawbot.
- Telegram: recover incomplete partial-stream previews by falling back to a final send when an ambiguous final edit failure would otherwise retain a strict prefix of the answer. Fixes #71525. (#71554) Thanks @sahilsatralkar.
- Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev.
- OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. (#71501) Thanks @vincentkoc and @wulala-xjj.
@@ -475,13 +242,6 @@ Docs: https://docs.openclaw.ai
- Codex: consume unauthorized bound conversation inbound claims before they can fall through to other claim handlers or enqueue Codex turns. (#71702) Thanks @vincentkoc.
- Codex media understanding: require approval-checked app-server image turns while explicitly declining tool, file, permission, and elicitation approval requests for the bounded image worker. (#71703) Thanks @vincentkoc.
- Agents/Claude CLI: allow large live `stream-json` JSONL lines up to the existing per-turn raw limit, preventing large Telegram, WebChat, MCP, and image turns from aborting on the old stdout buffer cap. Fixes #71793, #71080, and #70766. (#71897) Thanks @chacher86, @shivamgrover21, and @tpjordan.
- Agents/Claude CLI: unwrap nested Claude result envelopes in CLI JSON output so delegated agent responses surface as final text instead of raw result JSON. (#66819) Thanks @mraleko.
- Agents/Claude CLI: apply the configured 1M context window override to eligible Claude CLI Opus and Sonnet models when `context1m` is enabled. (#70863) Thanks @bidadh.
- Models/status: report fresh Claude CLI native auth instead of stale stored `anthropic:claude-cli` profile expiry when local credentials are current. Fixes #71256. (#71332) Thanks @matthiasjanke and @neeravmakwana.
- CLI backends: compact OpenClaw transcripts after over-budget CLI turns and reseed fresh CLI sessions from the compacted transcript instead of stale external resume state. Fixes #68329. (#71916) Thanks @obviyus.
- Telegram: keep default tool progress messages visible when answer preview streaming is disabled. (#71825) Thanks @VACInc.
- Configure/models: clear deselected model fallbacks when updating the model picker allowlist, including provider-scoped setup flows. (#71596) Thanks @rubencu.
- Agents/streaming: strip namespaced `<antml:thinking>` reasoning tags from streamed assistant replies before user-visible text is emitted. (#69288) Thanks @xialonglee.
## 2026.4.24
@@ -899,9 +659,16 @@ Docs: https://docs.openclaw.ai
### Fixes
- Dependencies: refresh workspace package pins and lockfile entries for AWS SDK, Anthropic SDK, ACP SDK, Matrix crypto, TypeBox, Vite, tsdown, Slack Bolt, CopilotKit AIMock, and related bundled plugin packages. Thanks @steipete.
- Gateway/env: import each missing expected login-shell env var independently, so an existing gateway token no longer prevents `env.shellEnv` from loading plugin credentials such as `TWILIO_*` from `.profile`. Thanks @steipete.
- macOS/Gateway pairing: silently accept same-host native app `metadata-upgrade` reconnects, so macOS patch-version changes update paired metadata instead of spamming security audit warnings and `pairing required` disconnects. Thanks @steipete.
- Dependencies: refresh workspace package pins and lockfile entries for AWS SDK,
Anthropic SDK, ACP SDK, Matrix crypto, TypeBox, Vite, tsdown, Slack Bolt,
CopilotKit AIMock, and related bundled plugin packages. Thanks @steipete.
- Gateway/env: import each missing expected login-shell env var independently,
so an existing gateway token no longer prevents `env.shellEnv` from loading
plugin credentials such as `TWILIO_*` from `.profile`. Thanks @steipete.
- macOS/Gateway pairing: silently accept same-host native app
`metadata-upgrade` reconnects, so macOS patch-version changes update paired
metadata instead of spamming security audit warnings and `pairing required`
disconnects. Thanks @steipete.
- CLI/Gateway: wait for one-shot gateway RPC clients to finish WebSocket teardown before the CLI process exits, reducing hangs where commands like `openclaw status` or `openclaw version` could finish their work but stay alive until an external timeout killed them (#70691). Thanks @Takhoffman.
- Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime (#70601). Thanks @Takhoffman.
- Gateway/model pricing: extend OpenRouter and LiteLLM catalog fetch timeouts to 60 seconds, reducing noisy timeout warnings during slow upstream responses. Thanks @steipete.
@@ -940,7 +707,9 @@ Docs: https://docs.openclaw.ai
- Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048.
- 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.
- 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 `plugins install` attempts at `plugins update` / `--force` instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.
- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded runs. `coding` and `messaging` sessions while preserving `minimal` profile and `tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818.
- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded runs.
`coding` and `messaging` sessions while preserving `minimal` profile and
`tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818.
- 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.
- 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.
- 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.

View File

@@ -9,19 +9,22 @@
# bundled plugin workspace tree, so the main build layer is not invalidated by
# unrelated plugin source changes.
#
# Build stages use full bookworm; the runtime image is always bookworm-slim.
# Two runtime variants:
# Default (bookworm): docker build .
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_VARIANT=default
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
ARG OPENCLAW_DOCKER_APT_UPGRADE=1
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
# Base images are pinned to SHA256 digests for reproducible builds.
# Dependabot refreshes these blessed digests; release builds consume the
# reviewed base snapshot instead of mutating distro state on every build.
# To update, run: docker buildx imagetools inspect node:24-bookworm and
# node:24-bookworm-slim (or podman) and replace the digests below with the
# current multi-arch manifest list entries.
# Trade-off: digests must be updated manually when upstream tags move.
# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman)
# and replace the digest below with the current multi-arch manifest list entry.
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
ARG OPENCLAW_EXTENSIONS
@@ -122,15 +125,22 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base image ─────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime
# ── Runtime base images ─────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
# ── Stage 3: Runtime ────────────────────────────────────────────
FROM base-runtime
FROM base-${OPENCLAW_VARIANT}
ARG OPENCLAW_VARIANT
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
ARG OPENCLAW_DOCKER_APT_UPGRADE
# OCI base-image metadata for downstream image consumers.
# If you change these annotations, also update:
@@ -145,10 +155,16 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
WORKDIR /app
# Install runtime system utilities missing from bookworm-slim.
# Install system utilities present in bookworm but missing in bookworm-slim.
# On the full bookworm image these are already installed (apt-get is a no-op).
# Smoke workflows can opt out of distro upgrades to cut repeated CI time while
# keeping the default runtime image behavior unchanged.
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \
if [ "${OPENCLAW_DOCKER_APT_UPGRADE}" != "0" ]; then \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends; \
fi && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git lsof openssl
@@ -157,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

@@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \

View File

@@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \

View File

@@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends ${PACKAGES}
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi

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

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

View File

@@ -1,9 +1,5 @@
# OpenClaw iOS Changelog
## 2026.4.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.

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.25
OPENCLAW_MARKETING_VERSION = 2026.4.25
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

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

View File

@@ -1,7 +1,6 @@
import AppKit
import Foundation
import Observation
import OpenClawKit
import ServiceManagement
import SwiftUI
@@ -367,8 +366,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 +435,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 +491,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 +569,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)
}

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.
@@ -500,10 +499,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.25</string>
<key>CFBundleVersion</key>
<string>2026042600</string>
<string>2026042500</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -17,7 +17,6 @@ final class VoiceSessionCoordinator {
var isFinal: Bool
var sendChime: VoiceWakeChime
var autoSendDelay: TimeInterval?
var voiceWakeTrigger: String?
}
private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.coordinator")
@@ -29,8 +28,7 @@ final class VoiceSessionCoordinator {
source: Source,
text: String,
attributed: NSAttributedString? = nil,
forwardEnabled: Bool = false,
voiceWakeTrigger: String? = nil) -> UUID
forwardEnabled: Bool = false) -> UUID
{
let token = UUID()
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
@@ -42,8 +40,7 @@ final class VoiceSessionCoordinator {
attributed: attributedText,
isFinal: false,
sendChime: .none,
autoSendDelay: nil,
voiceWakeTrigger: voiceWakeTrigger)
autoSendDelay: nil)
self.session = session
VoiceWakeOverlayController.shared.startSession(
token: token,
@@ -66,8 +63,7 @@ final class VoiceSessionCoordinator {
token: UUID,
text: String,
sendChime: VoiceWakeChime,
autoSendAfter: TimeInterval?,
voiceWakeTrigger: String? = nil)
autoSendAfter: TimeInterval?)
{
guard let session, session.token == token else { return }
self.logger
@@ -77,9 +73,6 @@ final class VoiceSessionCoordinator {
self.session?.isFinal = true
self.session?.sendChime = sendChime
self.session?.autoSendDelay = autoSendAfter
if let voiceWakeTrigger {
self.session?.voiceWakeTrigger = voiceWakeTrigger
}
let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text)
VoiceWakeOverlayController.shared.presentFinal(
@@ -93,20 +86,15 @@ final class VoiceSessionCoordinator {
func sendNow(token: UUID, reason: String = "explicit") {
guard let session, session.token == token else { return }
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
let voiceWakeTrigger = session.voiceWakeTrigger
let sendChime = session.sendChime
guard !text.isEmpty else {
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
self.clearSession()
return
}
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: sendChime)
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
Task.detached {
_ = await VoiceWakeForwarder.forward(
transcript: text,
options: .init(
voiceWakeTrigger: voiceWakeTrigger))
_ = await VoiceWakeForwarder.forward(transcript: text)
}
}

View File

@@ -38,7 +38,6 @@ enum VoiceWakeForwarder {
var deliver: Bool = true
var to: String?
var channel: GatewayAgentChannel = .webchat
var voiceWakeTrigger: String?
}
@discardableResult
@@ -54,8 +53,7 @@ enum VoiceWakeForwarder {
thinking: options.thinking,
deliver: deliver,
to: options.to,
channel: options.channel,
voiceWakeTrigger: options.voiceWakeTrigger))
channel: options.channel))
if result.ok {
self.logger.info("voice wake forward ok")

View File

@@ -41,11 +41,7 @@ enum VoiceWakeRecognitionDebugSupport {
minCommandLength: config.minCommandLength,
trimWake: trimWake)
else { return nil }
return WakeWordGateMatch(
triggerEndTime: 0,
postGap: 0,
command: command,
trigger: VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers))
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
}
static func transcriptSummary(

View File

@@ -37,7 +37,6 @@ actor VoiceWakeRuntime {
private var listeningState: ListeningState = .idle
private var overlayToken: UUID?
private var activeTriggerEndTime: TimeInterval?
private var activeTriggerWord: String?
private var scheduledRestartTask: Task<Void, Never>?
private var lastLoggedText: String?
private var lastLoggedAt: Date?
@@ -257,7 +256,6 @@ actor VoiceWakeRuntime {
self.currentConfig = nil
self.listeningState = .idle
self.activeTriggerEndTime = nil
self.activeTriggerWord = nil
self.logger.debug("voicewake runtime stopped")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
@@ -368,11 +366,7 @@ actor VoiceWakeRuntime {
} else {
self.logger.info("voicewake runtime detected len=\(match.command.count)")
}
await self.beginCapture(
command: match.command,
triggerEndTime: match.triggerEndTime,
triggerWord: match.trigger,
config: config)
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
} else if !transcript.isEmpty, update.error == nil {
if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) {
self.preDetectTask?.cancel()
@@ -500,33 +494,13 @@ actor VoiceWakeRuntime {
return
}
self.logger.info("voicewake runtime detected (trigger-only pause)")
let matchedTrigger = self.matchedTriggerWord(transcript: lastText, triggers: triggers)
await self.beginCapture(
command: "",
triggerEndTime: nil,
triggerWord: matchedTrigger,
config: config)
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
}
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
Self.isTriggerOnlyText(transcript: transcript, triggers: triggers)
}
private func matchedTriggerWord(transcript: String, triggers: [String]) -> String? {
Self.matchedTriggerWordText(transcript: transcript, triggers: triggers)
}
private static func isTriggerOnlyText(transcript: String, triggers: [String]) -> Bool {
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
guard
VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers)
|| VoiceWakeTextUtils.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
else { return false }
return self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
}
private static func matchedTriggerWordText(transcript: String, triggers: [String]) -> String? {
VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers)
guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false }
return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
}
private func preDetectSilenceCheck(
@@ -553,16 +527,10 @@ actor VoiceWakeRuntime {
await self.beginCapture(
command: match.command,
triggerEndTime: match.triggerEndTime,
triggerWord: match.trigger,
config: config)
}
private func beginCapture(
command: String,
triggerEndTime: TimeInterval?,
triggerWord: String?,
config: RuntimeConfig) async
{
private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async {
// When "Trigger Talk Mode" is enabled, skip the capture/overlay flow entirely
// and activate Talk Mode immediately. Talk Mode handles its own STT pipeline.
// Pause the wake listener to avoid two audio pipelines competing on the mic
@@ -577,6 +545,7 @@ actor VoiceWakeRuntime {
await AppStateStore.shared.setTalkEnabled(true)
return
}
self.listeningState = .voiceWake
self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
@@ -588,7 +557,6 @@ actor VoiceWakeRuntime {
self.heardBeyondTrigger = !command.isEmpty
self.triggerChimePlayed = false
self.activeTriggerEndTime = triggerEndTime
self.activeTriggerWord = triggerWord
self.preDetectTask?.cancel()
self.preDetectTask = nil
self.triggerOnlyTask?.cancel()
@@ -609,8 +577,7 @@ actor VoiceWakeRuntime {
source: .wakeWord,
text: snapshot,
attributed: attributed,
forwardEnabled: true,
voiceWakeTrigger: triggerWord)
forwardEnabled: true)
}
// Keep the "ears" boosted for the capture window so the status icon animates while recording.
@@ -665,9 +632,7 @@ actor VoiceWakeRuntime {
self.lastHeard = nil
self.heardBeyondTrigger = false
self.triggerChimePlayed = false
let triggerWord = self.activeTriggerWord
self.activeTriggerEndTime = nil
self.activeTriggerWord = nil
self.lastTranscript = nil
self.lastTranscriptAt = nil
self.preDetectTask?.cancel()
@@ -688,17 +653,14 @@ actor VoiceWakeRuntime {
token: token,
text: finalTranscript,
sendChime: sendChime,
autoSendAfter: delay,
voiceWakeTrigger: triggerWord)
autoSendAfter: delay)
}
} else if !finalTranscript.isEmpty {
if sendChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
}
Task.detached {
await VoiceWakeForwarder.forward(
transcript: finalTranscript,
options: .init(voiceWakeTrigger: triggerWord))
await VoiceWakeForwarder.forward(transcript: finalTranscript)
}
}
self.overlayToken = nil
@@ -822,14 +784,6 @@ actor VoiceWakeRuntime {
!self.trimmedAfterTrigger(text, triggers: triggers).isEmpty
}
static func _testIsTriggerOnly(_ text: String, triggers: [String]) -> Bool {
self.isTriggerOnlyText(transcript: text, triggers: triggers)
}
static func _testMatchedTriggerWord(_ text: String, triggers: [String]) -> String? {
self.matchedTriggerWordText(transcript: text, triggers: triggers)
}
static func _testAttributedColor(isFinal: Bool) -> NSColor {
VoiceOverlayTextFormatting.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear

View File

@@ -4,11 +4,6 @@ import SwabbleKit
enum VoiceWakeTextUtils {
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
.union(.symbols)
private static let wakePrefixFillers: Set<String> = [
"a", "ah", "eh", "er", "erm", "hey", "hmm", "huh", "mhm", "mm", "oh", "uh", "um",
"yo", "", "", "", "", "",
]
typealias TrimWake = (String, [String]) -> String
static func normalizeToken(_ token: String) -> String {
@@ -17,104 +12,6 @@ enum VoiceWakeTextUtils {
.lowercased()
}
private static func normalizedTriggerTokens(_ trigger: String) -> [String] {
trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
}
private static func isASCIIWordScalar(_ scalar: UnicodeScalar) -> Bool {
scalar.isASCII && CharacterSet.alphanumerics.contains(scalar)
}
private static func requiresASCIIWordBoundaries(_ value: String) -> Bool {
value.unicodeScalars.contains(where: self.isASCIIWordScalar)
}
private static func hasASCIIWordBoundaries(
transcript: String,
range: Range<String.Index>,
trigger: String) -> Bool
{
guard self.requiresASCIIWordBoundaries(trigger) else { return true }
if range.lowerBound > transcript.startIndex {
let beforeIndex = transcript.index(before: range.lowerBound)
let beforeScalars = transcript[beforeIndex].unicodeScalars
if beforeScalars.contains(where: self.isASCIIWordScalar) {
return false
}
}
if range.upperBound < transcript.endIndex {
let afterScalars = transcript[range.upperBound].unicodeScalars
if afterScalars.contains(where: self.isASCIIWordScalar) {
return false
}
}
return true
}
private static func bestRawTriggerMatch(
transcript: String,
triggers: [String]) -> (range: Range<String.Index>, normalizedTrigger: String)?
{
var bestMatch: (range: Range<String.Index>, normalizedTrigger: String, tokenCount: Int)?
for trigger in triggers {
let normalizedTokens = self.normalizedTriggerTokens(trigger)
guard !normalizedTokens.isEmpty else { continue }
let rawTrigger = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
let tokenCount = normalizedTokens.count
guard !rawTrigger.isEmpty else { continue }
var searchStart = transcript.startIndex
while searchStart < transcript.endIndex,
let range = transcript.range(
of: rawTrigger,
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
range: searchStart..<transcript.endIndex)
{
defer {
searchStart = transcript.index(after: range.lowerBound)
}
guard self.hasASCIIWordBoundaries(
transcript: transcript,
range: range,
trigger: rawTrigger)
else { continue }
if let bestMatch {
if range.lowerBound > bestMatch.range.lowerBound { continue }
if range.lowerBound == bestMatch.range.lowerBound,
tokenCount <= bestMatch.tokenCount
{
continue
}
}
bestMatch = (range, normalizedTokens.joined(separator: " "), tokenCount)
break
}
if let bestMatch,
bestMatch.range.lowerBound == transcript.startIndex,
bestMatch.tokenCount >= tokenCount
{
// Earlier matches take precedence, so once we match from the
// start there is no need to scan later triggers with fewer
// tokens at the same offset.
if bestMatch.tokenCount > tokenCount {
continue
}
}
}
return bestMatch.map { (range: $0.range, normalizedTrigger: $0.normalizedTrigger) }
}
static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool {
let tokens = transcript
.split(whereSeparator: { $0.isWhitespace })
@@ -122,7 +19,10 @@ enum VoiceWakeTextUtils {
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return false }
for trigger in triggers {
let triggerTokens = self.normalizedTriggerTokens(trigger)
let triggerTokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue }
if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) {
return true
@@ -140,55 +40,9 @@ enum VoiceWakeTextUtils {
guard !transcript.isEmpty else { return nil }
guard !self.normalizeToken(transcript).isEmpty else { return nil }
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
guard
self.startsWithTrigger(transcript: transcript, triggers: triggers)
|| self.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
else { return nil }
guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
let trimmed = trimWake(transcript, triggers)
guard trimmed.count >= minCommandLength else { return nil }
return trimmed
}
static func hasOnlyFillerBeforeTrigger(transcript: String, triggers: [String]) -> Bool {
guard let match = self.bestRawTriggerMatch(transcript: transcript, triggers: triggers) else { return false }
let prefixTokens = transcript[..<match.range.lowerBound]
.split(whereSeparator: {
$0.isWhitespace || self.whitespaceAndPunctuation.contains($0.unicodeScalars.first!)
})
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
return prefixTokens.allSatisfy { self.wakePrefixFillers.contains($0) }
}
static func matchedTriggerWord(transcript: String, triggers: [String]) -> String? {
if let rawMatch = self.bestRawTriggerMatch(transcript: transcript, triggers: triggers) {
return rawMatch.normalizedTrigger
}
let transcriptTokens = transcript
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
guard !transcriptTokens.isEmpty else { return nil }
var bestStartIndex = Int.max
var bestTokenCount = -1
var bestTokens: [String]?
for trigger in triggers {
let triggerTokens = self.normalizedTriggerTokens(trigger)
guard !triggerTokens.isEmpty, transcriptTokens.count >= triggerTokens.count else { continue }
for index in 0...(transcriptTokens.count - triggerTokens.count) {
let candidate = transcriptTokens[index..<(index + triggerTokens.count)]
guard zip(triggerTokens, candidate).allSatisfy({ $0 == $1 }) else { continue }
if index < bestStartIndex || (index == bestStartIndex && triggerTokens.count > bestTokenCount) {
bestStartIndex = index
bestTokenCount = triggerTokens.count
bestTokens = triggerTokens
}
}
}
return bestTokens?.joined(separator: " ")
}
}

View File

@@ -595,14 +595,11 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let modelrun: Bool?
public let promptmode: AnyCodable?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let voicewaketrigger: String?
public let idempotencykey: String
public let label: String?
@@ -630,14 +627,11 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
modelrun: Bool?,
promptmode: AnyCodable?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
voicewaketrigger: String?,
idempotencykey: String,
label: String?)
{
@@ -664,14 +658,11 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.modelrun = modelrun
self.promptmode = promptmode
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.voicewaketrigger = voicewaketrigger
self.idempotencykey = idempotencykey
self.label = label
}
@@ -700,14 +691,11 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case modelrun = "modelRun"
case promptmode = "promptMode"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case voicewaketrigger = "voiceWakeTrigger"
case idempotencykey = "idempotencyKey"
case label
}

View File

@@ -1,4 +1,3 @@
import Foundation
import Testing
@testable import OpenClaw
@@ -37,130 +36,6 @@ struct AppStateRemoteConfigTests {
#expect((remote["token"] as? String) == nil)
}
@Test
func updatedRemoteGatewayConfigPinsLoopbackUrlForSshTransport() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://gateway.example:18789"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
#expect((remote["transport"] as? String) == nil)
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
}
@Test
func updatedRemoteGatewayConfigPreservesCustomLoopbackTunnelPort() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://localhost.:29876"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
@Test
func updatedRemoteGatewayConfigPreservesCustomPortWhenExistingHostMatchesSshTarget() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://gateway.example:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
}
@Test
func updatedRemoteGatewayConfigDropsCustomPortWhenExistingHostDoesNotMatchSshTarget() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://other-host.example:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@Test
func updatedRemoteGatewayConfigDoesNotPreservePortForHostnamePrefixCollision() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://example.attacker.tld:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "alice@example.com",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@Test
func appStateInitDoesNotInferLoopbackHostIntoRemoteTarget() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: nil])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:19999",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTarget == "")
}
}
@Test
func appStateInitPreservesExistingRemoteTargetWhenRemoteUrlIsLoopback() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: "alice@gateway.example"])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:19999",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTarget == "alice@gateway.example")
}
}
@Test
func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() {
let initialRoot: [String: Any] = [

View File

@@ -6,10 +6,6 @@ import Testing
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
var state: URLSessionTask.State = .running
var autoRespond = false
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
private var sentChallenge = false
private var respondedRequestIds = Set<String>()
func resume() {}
@@ -17,90 +13,41 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
self.state = .canceling
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
self.sentMessages.append(message)
}
func send(_: URLSessionWebSocketTask.Message) async throws {}
func receive() async throws -> URLSessionWebSocketTask.Message {
if self.autoRespond {
if !self.sentChallenge {
self.sentChallenge = true
return .string("""
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}}
""")
}
if let request = self.latestUnrespondedRequest() {
self.respondedRequestIds.insert(request.id)
if request.method == "connect" {
return .string("""
{"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"policy":{}}}
""")
}
return .string("""
{"type":"res","id":"\(request.id)","ok":true,"payload":{}}
""")
}
}
throw URLError(.cannotConnectToHost)
}
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
completionHandler(.failure(URLError(.cannotConnectToHost)))
}
private func latestUnrespondedRequest() -> (id: String, method: String)? {
for message in self.sentMessages.reversed() {
let data: Data?
switch message {
case .string(let text):
data = Data(text.utf8)
case .data(let raw):
data = raw
@unknown default:
data = nil
}
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? String,
let method = json["method"] as? String,
!self.respondedRequestIds.contains(id)
else {
continue
}
return (id, method)
}
return nil
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
let task = FakeWebSocketTask()
func makeWebSocketTask(url _: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: self.task)
WebSocketTaskBox(task: FakeWebSocketTask())
}
}
private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSession) {
let session = FakeWebSocketSession()
let connection = GatewayConnection(
private func makeTestGatewayConnection() -> GatewayConnection {
GatewayConnection(
configProvider: {
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
},
sessionBox: WebSocketSessionBox(session: session))
return (connection, session)
sessionBox: WebSocketSessionBox(session: FakeWebSocketSession()))
}
@Suite(.serialized) struct GatewayConnectionControlTests {
@Test func `status fails when process missing`() async {
let (connection, _) = makeTestGatewayConnection()
let connection = makeTestGatewayConnection()
let result = await connection.status()
#expect(result.ok == false)
#expect(result.error != nil)
}
@Test func `reject empty message`() async {
let (connection, _) = makeTestGatewayConnection()
let connection = makeTestGatewayConnection()
let result = await connection.sendAgent(
message: "",
thinking: nil,
@@ -109,38 +56,4 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
to: nil)
#expect(result.ok == false)
}
@Test func `send agent keeps empty voice wake trigger field`() async throws {
let (connection, session) = makeTestGatewayConnection()
session.task.autoRespond = true
_ = await connection.sendAgent(GatewayAgentInvocation(
message: "test",
sessionKey: "main",
thinking: nil,
deliver: false,
to: nil,
channel: .last,
timeoutSeconds: nil,
idempotencyKey: "idem-1",
voiceWakeTrigger: " "))
guard let lastMessage = session.task.sentMessages.last else {
Issue.record("expected websocket send payload")
return
}
let payloadData: Data
switch lastMessage {
case .string(let text):
payloadData = Data(text.utf8)
case .data(let data):
payloadData = data
@unknown default:
Issue.record("unexpected websocket message type")
return
}
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
let params = json?["params"] as? [String: Any]
#expect(params?["voiceWakeTrigger"] as? String == "")
}
}

View File

@@ -84,35 +84,7 @@ struct GatewayDiscoverySelectionSupportTests {
state: state)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
let configRoot = OpenClawConfigFile.loadDict()
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
}
@Test func `selecting nearby lan gateway preserves existing ssh tunnel port`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "ws://localhost:29876"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: "nearby-gateway.local",
servicePort: 19999,
stableID: "bonjour|nearby-gateway-custom"),
state: state)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
let configRoot = OpenClawConfigFile.loadDict()
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
}
}

View File

@@ -83,28 +83,4 @@ struct MacNodeBrowserProxyTests {
let arr = try #require(parsed["arr"] as? [Any])
#expect(arr.count == 2)
}
@Test func requestReportsActionableUnavailableWhenControlServiceIsMissing() async throws {
let proxy = MacNodeBrowserProxy(
endpointProvider: {
MacNodeBrowserProxy.Endpoint(
baseURL: URL(string: "http://127.0.0.1:18791")!,
token: nil,
password: nil)
},
performRequest: { _ in
throw URLError(.cannotConnectToHost)
})
do {
_ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#)
Issue.record("request should fail when browser control is unreachable")
} catch {
let message = error.localizedDescription
#expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service"))
#expect(message.contains("http://127.0.0.1:18791"))
#expect(message.contains("browser control is owned by the CLI node-host"))
#expect(message.contains("openclaw node start"))
}
}
}

View File

@@ -1,32 +0,0 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
struct MacNodeModeCoordinatorTests {
@Test func remoteModeDoesNotAdvertiseBrowserProxy() {
let caps = MacNodeModeCoordinator.resolvedCaps(
browserControlEnabled: true,
cameraEnabled: false,
locationMode: .off,
connectionMode: .remote)
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
}
@Test func localModeAdvertisesBrowserProxyWhenEnabled() {
let caps = MacNodeModeCoordinator.resolvedCaps(
browserControlEnabled: true,
cameraEnabled: false,
locationMode: .off,
connectionMode: .local)
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
#expect(caps.contains(OpenClawCapability.browser.rawValue))
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
}
}

View File

@@ -35,30 +35,8 @@ struct OpenClawConfigFileTests {
])
#expect(OpenClawConfigFile.remoteGatewayPort() == 19999)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "GATEWAY.ts.net.") == 19999)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == nil)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.attacker.tld") == nil)
}
}
@MainActor
@Test
func `set remote gateway url string replaces scheme`() async {
let override = self.makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
"gateway": [
"remote": [
"url": "wss://old-host:111",
],
],
])
OpenClawConfigFile.setRemoteGatewayUrlString("ws://127.0.0.1:18789")
let root = OpenClawConfigFile.loadDict()
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
#expect(url == "ws://127.0.0.1:18789")
}
}

View File

@@ -35,80 +35,6 @@ struct VoiceWakeRuntimeTests {
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
}
@Test func `trigger only allows filler before trigger`() {
let triggers = ["openclaw"]
let text = "uh openclaw"
#expect(VoiceWakeRuntime._testIsTriggerOnly(text, triggers: triggers))
}
@Test func `trigger only rejects trailing wake word mentions in ordinary speech`() {
let triggers = ["openclaw"]
let text = "tell me about openclaw"
#expect(!VoiceWakeRuntime._testIsTriggerOnly(text, triggers: triggers))
}
@Test func `matched trigger finds trigger not at transcript start`() {
let triggers = ["openclaw"]
let text = "uh openclaw"
#expect(VoiceWakeRuntime._testMatchedTriggerWord(text, triggers: triggers) == "openclaw")
}
@Test func `matched trigger rejects larger word suffix matches`() {
let triggers = ["computer"]
let text = "uh computers"
#expect(VoiceWakeRuntime._testMatchedTriggerWord(text, triggers: triggers) == nil)
}
@Test func `matched trigger prefers most specific overlapping phrase`() {
let triggers = ["openclaw", "hey openclaw"]
let text = "hey openclaw"
#expect(VoiceWakeRuntime._testMatchedTriggerWord(text, triggers: triggers) == "hey openclaw")
}
@Test func `matched trigger handles width insensitive forms without whitespace tokens`() {
let triggers = ["openclaw"]
let text = ""
#expect(VoiceWakeRuntime._testMatchedTriggerWord(text, triggers: triggers) == "openclaw")
}
@Test func `matched trigger handles chinese forms without whitespace tokens`() {
let triggers = ["小爪"]
let text = "嘿小爪"
#expect(VoiceWakeRuntime._testMatchedTriggerWord(text, triggers: triggers) == "小爪")
}
@Test func `text only fallback populates matched trigger`() {
let transcript = "hey openclaw do thing"
let config = WakeWordGateConfig(triggers: ["openclaw"], minCommandLength: 1)
let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: transcript,
triggers: ["openclaw"],
config: config,
trimWake: VoiceWakeRuntime._testTrimmedAfterTrigger)
#expect(match?.trigger == "openclaw")
}
@Test func `text only fallback keeps the first trigger phrase when later words match another trigger`() {
let transcript = "openclaw tell me about computer vision"
let config = WakeWordGateConfig(triggers: ["openclaw", "computer"], minCommandLength: 1)
let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: transcript,
triggers: ["openclaw", "computer"],
config: config,
trimWake: VoiceWakeRuntime._testTrimmedAfterTrigger)
#expect(match?.trigger == "openclaw")
}
@Test func `text only fallback rejects filler prefixed larger word suffix matches`() {
let transcript = "uh computers"
let config = WakeWordGateConfig(triggers: ["computer"], minCommandLength: 1)
let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: transcript,
triggers: ["computer"],
config: config,
trimWake: VoiceWakeRuntime._testTrimmedAfterTrigger)
#expect(match == nil)
}
@Test func `trims after chinese trigger keeps post speech`() {
let triggers = ["小爪", "openclaw"]
let text = "嘿 小爪 帮我打开设置"

View File

@@ -595,14 +595,11 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let modelrun: Bool?
public let promptmode: AnyCodable?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let voicewaketrigger: String?
public let idempotencykey: String
public let label: String?
@@ -630,14 +627,11 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
modelrun: Bool?,
promptmode: AnyCodable?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
voicewaketrigger: String?,
idempotencykey: String,
label: String?)
{
@@ -664,14 +658,11 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.modelrun = modelrun
self.promptmode = promptmode
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.voicewaketrigger = voicewaketrigger
self.idempotencykey = idempotencykey
self.label = label
}
@@ -700,14 +691,11 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case modelrun = "modelRun"
case promptmode = "promptMode"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case voicewaketrigger = "voiceWakeTrigger"
case idempotencykey = "idempotencyKey"
case label
}

View File

@@ -6,19 +6,6 @@ services:
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-}
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
# Empty means auto: Bonjour disables itself in detected containers.
# Set 0 only on host/macvlan/mDNS-capable networks; set 1 to force off.
OPENCLAW_DISABLE_BONJOUR: ${OPENCLAW_DISABLE_BONJOUR:-}
# OpenTelemetry export is outbound OTLP/HTTP from the Gateway. Prometheus
# uses the existing authenticated Gateway route; it does not need a port.
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-}
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: ${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SEMCONV_STABILITY_OPT_IN: ${OTEL_SEMCONV_STABILITY_OPT_IN:-}
OPENCLAW_OTEL_PRELOADED: ${OPENCLAW_OTEL_PRELOADED:-}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}

View File

@@ -1,4 +1,4 @@
c4b54de7557cd14b35a629585ad706a4e7de411cc725bcbce921f22bfaf14ada config-baseline.json
3fd4da36f28b508f8e6ac4fceb18262244d8ed70df15244192032ec71027bb4f config-baseline.core.json
07963db49502132f26db396c56b36e018b110e6c55a68b3cb012d3ec96f43901 config-baseline.channel.json
74b74cb18ac37c0acaa765f398f1f9edbcee4c43567f02d45c89598a1e13afb4 config-baseline.plugin.json
3efb041739877bd5387ffc87e0ddd11be43d80d38e7779407ce8091dcb797e5e config-baseline.json
5c6e35c5846f654d717d4b20853649e0b45a746423834f539b2a2223abcd5226 config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
a5479c182ec987bb21e814b8a4e7b3bda7190ae5c2b35fd5ca403dfa48afa115 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2a3fb85feb7420de8b166a695c3693dcc1eaa7a7f31de0dd139da856f10b2085 plugin-sdk-api-baseline.json
6bdb96f7f92c34d7ae698784c0073343c34fb4274ab7eeded49acebb81056074 plugin-sdk-api-baseline.jsonl
690c1cd4c0c2c3d31577958120e14ac0bf555af529e03aa5e7965b1d04659c49 plugin-sdk-api-baseline.json
a0e6ba472ddd3acea34c0a8fda8cbb7d1172b1671a671d5fef5a9f42d749ce0d plugin-sdk-api-baseline.jsonl

View File

@@ -3,7 +3,7 @@ summary: "Redirect to /gateway/authentication"
title: "Auth monitoring"
---
Auth monitoring lives under [Authentication](/gateway/authentication).
This page moved to [Authentication](/gateway/authentication). See [Authentication](/gateway/authentication) for auth monitoring documentation.
## Related

View File

@@ -3,7 +3,7 @@ summary: "Redirect to Task Flow"
title: "ClawFlow"
---
ClawFlow was renamed to [Task flow](/automation/taskflow).
ClawFlow was renamed to [Task Flow](/automation/taskflow). See [Task Flow](/automation/taskflow) for the current documentation.
## Related

View File

@@ -5,37 +5,29 @@ read_when:
- Wiring external triggers (webhooks, Gmail) into OpenClaw
- Deciding between heartbeat and cron for scheduled tasks
title: "Scheduled tasks"
sidebarTitle: "Scheduled tasks"
---
Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at the right time, and can deliver output back to a chat channel or webhook endpoint.
## Quick start
<Steps>
<Step title="Add a one-shot reminder">
```bash
openclaw cron add \
--name "Reminder" \
--at "2026-02-01T16:00:00Z" \
--session main \
--system-event "Reminder: check the cron docs draft" \
--wake now \
--delete-after-run
```
</Step>
<Step title="Check your jobs">
```bash
openclaw cron list
openclaw cron show <job-id>
```
</Step>
<Step title="See run history">
```bash
openclaw cron runs --id <job-id>
```
</Step>
</Steps>
```bash
# Add a one-shot reminder
openclaw cron add \
--name "Reminder" \
--at "2026-02-01T16:00:00Z" \
--session main \
--system-event "Reminder: check the cron docs draft" \
--wake now \
--delete-after-run
# Check your jobs
openclaw cron list
openclaw cron show <job-id>
# See run history
openclaw cron runs --id <job-id>
```
## How cron works
@@ -46,15 +38,18 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
- All cron executions create [background task](/automation/tasks) records.
- One-shot jobs (`--at`) auto-delete after success by default.
- Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.
- Isolated cron runs also guard against stale acknowledgement replies. If the first result is just an interim status update (`on it`, `pulling everything together`, and similar hints) and no descendant subagent run is still responsible for the final answer, OpenClaw re-prompts once for the actual result before delivery.
- Isolated cron runs prefer structured execution-denial metadata from the embedded run, then fall back to known final summary/output markers such as `SYSTEM_RUN_DENIED` and `INVALID_REQUEST`, so a blocked command is not reported as a green run.
- Isolated cron runs also treat run-level agent failures as job errors even when no reply payload is produced, so model/provider failures increment error counters and trigger failure notifications instead of clearing the job as successful.
- Isolated cron runs also guard against stale acknowledgement replies. If the
first result is just an interim status update (`on it`, `pulling everything
together`, and similar hints) and no descendant subagent run is still
responsible for the final answer, OpenClaw re-prompts once for the actual
result before delivery.
<a id="maintenance"></a>
<Note>
Task reconciliation for cron is runtime-owned first, durable-history-backed second: an active cron task stays live while the cron runtime still tracks that job as running, even if an old child session row still exists. Once the runtime stops owning the job and the 5-minute grace window expires, maintenance checks persisted run logs and job state for the matching `cron:<jobId>:<startedAt>` run. If that durable history shows a terminal result, the task ledger is finalized from it; otherwise Gateway-owned maintenance can mark the task `lost`. Offline CLI audit can recover from durable history, but it does not treat its own empty in-process active-job set as proof that a Gateway-owned cron run is gone.
</Note>
Task reconciliation for cron is runtime-owned: an active cron task stays live while the
cron runtime still tracks that job as running, even if an old child session row still exists.
Once the runtime stops owning the job and the 5-minute grace window expires, maintenance can
mark the task `lost`.
## Schedule types
@@ -89,46 +84,35 @@ This fires ~56 times per month instead of 01 times per month. OpenClaw use
| Current session | `current` | Bound at creation time | Context-aware recurring work |
| Custom session | `session:custom-id` | Persistent named session | Workflows that build on history |
<AccordionGroup>
<Accordion title="Main session vs isolated vs custom">
**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). Those system events do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
</Accordion>
<Accordion title="What 'fresh session' means for isolated jobs">
For isolated jobs, "fresh session" means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:<id>` when a recurring job should deliberately build on the same conversation context.
</Accordion>
<Accordion title="Runtime cleanup">
For isolated jobs, runtime teardown now includes best-effort browser cleanup for that cron session. Cleanup failures are ignored so the actual cron result still wins.
**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). Those system events do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
Isolated cron runs also dispose any bundled MCP runtime instances created for the job through the shared runtime-cleanup path. This matches how main-session and custom-session MCP clients are torn down, so isolated cron jobs do not leak stdio child processes or long-lived MCP connections across runs.
For isolated jobs, “fresh session” means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:<id>` when a recurring job should deliberately build on the same conversation context.
</Accordion>
<Accordion title="Subagent and Discord delivery">
When isolated cron runs orchestrate subagents, delivery also prefers the final descendant output over stale parent interim text. If descendants are still running, OpenClaw suppresses that partial parent update instead of announcing it.
For isolated jobs, runtime teardown now includes best-effort browser cleanup for that cron session. Cleanup failures are ignored so the actual cron result still wins.
For text-only Discord announce targets, OpenClaw sends the canonical final assistant text once instead of replaying both streamed/intermediate text payloads and the final answer. Media and structured Discord payloads are still delivered as separate payloads so attachments and components are not dropped.
Isolated cron runs also dispose any bundled MCP runtime instances created for the job through the shared runtime-cleanup path. This matches how main-session and custom-session MCP clients are torn down, so isolated cron jobs do not leak stdio child processes or long-lived MCP connections across runs.
</Accordion>
</AccordionGroup>
When isolated cron runs orchestrate subagents, delivery also prefers the final
descendant output over stale parent interim text. If descendants are still
running, OpenClaw suppresses that partial parent update instead of announcing it.
For text-only Discord announce targets, OpenClaw sends the canonical final
assistant text once instead of replaying both streamed/intermediate text payloads
and the final answer. Media and structured Discord payloads are still delivered
as separate payloads so attachments and components are not dropped.
### Payload options for isolated jobs
<ParamField path="--message" type="string" required>
Prompt text (required for isolated).
</ParamField>
<ParamField path="--model" type="string">
Model override; uses the selected allowed model for the job.
</ParamField>
<ParamField path="--thinking" type="string">
Thinking level override.
</ParamField>
<ParamField path="--light-context" type="boolean">
Skip workspace bootstrap file injection.
</ParamField>
<ParamField path="--tools" type="string">
Restrict which tools the job can use, for example `--tools exec,read`.
</ParamField>
- `--message`: prompt text (required for isolated)
- `--model` / `--thinking`: model and thinking level overrides
- `--light-context`: skip workspace bootstrap file injection
- `--tools exec,read`: restrict which tools the job can use
`--model` uses the selected allowed model for that job. If the requested model is not allowed, cron logs a warning and falls back to the job's agent/default model selection instead. Configured fallback chains still apply, but a plain model override with no explicit per-job fallback list no longer appends the agent primary as a hidden extra retry target.
`--model` uses the selected allowed model for that job. If the requested model
is not allowed, cron logs a warning and falls back to the job's agent/default
model selection instead. Configured fallback chains still apply, but a plain
model override with no explicit per-job fallback list no longer appends the
agent primary as a hidden extra retry target.
Model-selection precedence for isolated jobs is:
@@ -137,9 +121,16 @@ Model-selection precedence for isolated jobs is:
3. User-selected stored cron session model override
4. Agent/default model selection
Fast mode follows the resolved live selection too. If the selected model config has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction.
Fast mode follows the resolved live selection too. If the selected model config
has `params.fastMode`, isolated cron uses that by default. A stored session
`fastMode` override still wins over config in either direction.
If an isolated run hits a live model-switch handoff, cron retries with the switched provider/model and persists that live selection for the active run before retrying. When the switch also carries a new auth profile, cron persists that auth profile override for the active run too. Retries are bounded: after the initial attempt plus 2 switch retries, cron aborts instead of looping forever.
If an isolated run hits a live model-switch handoff, cron retries with the
switched provider/model and persists that live selection for the active run
before retrying. When the switch also carries a new auth profile, cron persists
that auth profile override for the active run too. Retries are bounded: after
the initial attempt plus 2 switch retries, cron aborts instead of looping
forever.
## Delivery and output
@@ -151,9 +142,16 @@ If an isolated run hits a live model-switch handoff, cron retries with the switc
Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:<id>`, `user:<id>`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix.
For isolated jobs, chat delivery is shared. If a chat route is available, the agent can use the `message` tool even when the job uses `--no-deliver`. If the agent sends to the configured/current target, OpenClaw skips the fallback announce. Otherwise `announce`, `webhook`, and `none` only control what the runner does with the final reply after the agent turn.
For isolated jobs, chat delivery is shared. If a chat route is available, the
agent can use the `message` tool even when the job uses `--no-deliver`. If the
agent sends to the configured/current target, OpenClaw skips the fallback
announce. Otherwise `announce`, `webhook`, and `none` only control what the
runner does with the final reply after the agent turn.
When an agent creates an isolated reminder from an active chat, OpenClaw stores the preserved live delivery target for the fallback announce route. Internal session keys may be lowercase; provider delivery targets are not reconstructed from those keys when current chat context is available.
When an agent creates an isolated reminder from an active chat, OpenClaw stores
the preserved live delivery target for the fallback announce route. Internal
session keys may be lowercase; provider delivery targets are not reconstructed
from those keys when current chat context is available.
Failure notifications follow a separate destination path:
@@ -164,44 +162,44 @@ Failure notifications follow a separate destination path:
## CLI examples
<Tabs>
<Tab title="One-shot reminder">
```bash
openclaw cron add \
--name "Calendar check" \
--at "20m" \
--session main \
--system-event "Next heartbeat: check calendar." \
--wake now
```
</Tab>
<Tab title="Recurring isolated job">
```bash
openclaw cron add \
--name "Morning brief" \
--cron "0 7 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--announce \
--channel slack \
--to "channel:C1234567890"
```
</Tab>
<Tab title="Model and thinking override">
```bash
openclaw cron add \
--name "Deep analysis" \
--cron "0 6 * * 1" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--announce
```
</Tab>
</Tabs>
One-shot reminder (main session):
```bash
openclaw cron add \
--name "Calendar check" \
--at "20m" \
--session main \
--system-event "Next heartbeat: check calendar." \
--wake now
```
Recurring isolated job with delivery:
```bash
openclaw cron add \
--name "Morning brief" \
--cron "0 7 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--announce \
--channel slack \
--to "channel:C1234567890"
```
Isolated job with model and thinking override:
```bash
openclaw cron add \
--name "Deep analysis" \
--cron "0 6 * * 1" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--announce
```
## Webhooks
@@ -226,61 +224,52 @@ Every request must include the hook token via header:
Query-string tokens are rejected.
<AccordionGroup>
<Accordion title="POST /hooks/wake">
Enqueue a system event for the main session:
### POST /hooks/wake
```bash
curl -X POST http://127.0.0.1:18789/hooks/wake \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"text":"New email received","mode":"now"}'
```
Enqueue a system event for the main session:
<ParamField path="text" type="string" required>
Event description.
</ParamField>
<ParamField path="mode" type="string" default="now">
`now` or `next-heartbeat`.
</ParamField>
```bash
curl -X POST http://127.0.0.1:18789/hooks/wake \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"text":"New email received","mode":"now"}'
```
</Accordion>
<Accordion title="POST /hooks/agent">
Run an isolated agent turn:
- `text` (required): event description
- `mode` (optional): `now` (default) or `next-heartbeat`
```bash
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.4"}'
```
### POST /hooks/agent
Fields: `message` (required), `name`, `agentId`, `wakeMode`, `deliver`, `channel`, `to`, `model`, `thinking`, `timeoutSeconds`.
Run an isolated agent turn:
</Accordion>
<Accordion title="Mapped hooks (POST /hooks/<name>)">
Custom hook names are resolved via `hooks.mappings` in config. Mappings can transform arbitrary payloads into `wake` or `agent` actions with templates or code transforms.
</Accordion>
</AccordionGroup>
```bash
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.4"}'
```
<Warning>
Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
Fields: `message` (required), `name`, `agentId`, `wakeMode`, `deliver`, `channel`, `to`, `model`, `thinking`, `timeoutSeconds`.
### Mapped hooks (POST /hooks/\<name\>)
Custom hook names are resolved via `hooks.mappings` in config. Mappings can transform arbitrary payloads into `wake` or `agent` actions with templates or code transforms.
### Security
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Keep `hooks.path` on a dedicated subpath; `/` is rejected.
- Set `hooks.allowedAgentIds` to limit explicit `agentId` routing.
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
- If you enable `hooks.allowRequestSessionKey`, also set `hooks.allowedSessionKeyPrefixes` to constrain allowed session key shapes.
- Hook payloads are wrapped with safety boundaries by default.
</Warning>
## Gmail PubSub integration
Wire Gmail inbox triggers to OpenClaw via Google PubSub.
<Note>
**Prerequisites:** `gcloud` CLI, `gog` (gogcli), OpenClaw hooks enabled, Tailscale for the public HTTPS endpoint.
</Note>
**Prerequisites**: `gcloud` CLI, `gog` (gogcli), OpenClaw hooks enabled, Tailscale for the public HTTPS endpoint.
### Wizard setup (recommended)
@@ -296,34 +285,31 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
### Manual one-time setup
<Steps>
<Step title="Select the GCP project">
Select the GCP project that owns the OAuth client used by `gog`:
1. Select the GCP project that owns the OAuth client used by `gog`:
```bash
gcloud auth login
gcloud config set project <project-id>
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
```
```bash
gcloud auth login
gcloud config set project <project-id>
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
```
</Step>
<Step title="Create topic and grant Gmail push access">
```bash
gcloud pubsub topics create gog-gmail-watch
gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
--role=roles/pubsub.publisher
```
</Step>
<Step title="Start the watch">
```bash
gog gmail watch start \
--account openclaw@gmail.com \
--label INBOX \
--topic projects/<project-id>/topics/gog-gmail-watch
```
</Step>
</Steps>
2. Create topic and grant Gmail push access:
```bash
gcloud pubsub topics create gog-gmail-watch
gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
--role=roles/pubsub.publisher
```
3. Start the watch:
```bash
gog gmail watch start \
--account openclaw@gmail.com \
--label INBOX \
--topic projects/<project-id>/topics/gog-gmail-watch
```
### Gmail model override
@@ -367,14 +353,16 @@ openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --mes
openclaw cron edit <jobId> --clear-agent
```
<Note>
Model override note:
- `openclaw cron add|edit --model ...` changes the job's selected model.
- If the model is allowed, that exact provider/model reaches the isolated agent run.
- If it is not allowed, cron warns and falls back to the job's agent/default model selection.
- Configured fallback chains still apply, but a plain `--model` override with no explicit per-job fallback list no longer falls through to the agent primary as a silent extra retry target.
</Note>
- If the model is allowed, that exact provider/model reaches the isolated agent
run.
- If it is not allowed, cron warns and falls back to the job's agent/default
model selection.
- Configured fallback chains still apply, but a plain `--model` override with
no explicit per-job fallback list no longer falls through to the agent
primary as a silent extra retry target.
## Configuration
@@ -396,21 +384,17 @@ Model override note:
}
```
The runtime state sidecar is derived from `cron.store`: a `.json` store such as `~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path without a `.json` suffix appends `-state.json`.
The runtime state sidecar is derived from `cron.store`: a `.json` store such as
`~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path
without a `.json` suffix appends `-state.json`.
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
<AccordionGroup>
<Accordion title="Retry behavior">
**One-shot retry**: transient errors (rate limit, overload, network, server error) retry up to 3 times with exponential backoff. Permanent errors disable immediately.
**One-shot retry**: transient errors (rate limit, overload, network, server error) retry up to 3 times with exponential backoff. Permanent errors disable immediately.
**Recurring retry**: exponential backoff (30s to 60m) between retries. Backoff resets after the next successful run.
**Recurring retry**: exponential backoff (30s to 60m) between retries. Backoff resets after the next successful run.
</Accordion>
<Accordion title="Maintenance">
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
</Accordion>
</AccordionGroup>
**Maintenance**: `cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
## Troubleshooting
@@ -427,32 +411,45 @@ openclaw logs --follow
openclaw doctor
```
<AccordionGroup>
<Accordion title="Cron not firing">
- Check `cron.enabled` and `OPENCLAW_SKIP_CRON` env var.
- Confirm the Gateway is running continuously.
- For `cron` schedules, verify timezone (`--tz`) vs the host timezone.
- `reason: not-due` in run output means manual run was checked with `openclaw cron run <jobId> --due` and the job was not due yet.
</Accordion>
<Accordion title="Cron fired but no delivery">
- Delivery mode `none` means no runner fallback send is expected. The agent can still send directly with the `message` tool when a chat route is available.
- Delivery target missing/invalid (`channel`/`to`) means outbound was skipped.
- For Matrix, copied or legacy jobs with lowercased `delivery.to` room IDs can fail because Matrix room IDs are case-sensitive. Edit the job to the exact `!room:server` or `room:!room:server` value from Matrix.
- Channel auth errors (`unauthorized`, `Forbidden`) mean delivery was blocked by credentials.
- If the isolated run returns only the silent token (`NO_REPLY` / `no_reply`), OpenClaw suppresses direct outbound delivery and also suppresses the fallback queued summary path, so nothing is posted back to chat.
- If the agent should message the user itself, check that the job has a usable route (`channel: "last"` with a previous chat, or an explicit channel/target).
</Accordion>
<Accordion title="Cron or heartbeat appears to prevent /new-style rollover">
- Daily and idle reset freshness is not based on `updatedAt`; see [Session management](/concepts/session#session-lifecycle).
- Cron wakeups, heartbeat runs, exec notifications, and gateway bookkeeping may update the session row for routing/status, but they do not extend `sessionStartedAt` or `lastInteractionAt`.
- For legacy rows created before those fields existed, OpenClaw can recover `sessionStartedAt` from the transcript JSONL session header when the file is still available. Legacy idle rows without `lastInteractionAt` use that recovered start time as their idle baseline.
</Accordion>
<Accordion title="Timezone gotchas">
- Cron without `--tz` uses the gateway host timezone.
- `at` schedules without timezone are treated as UTC.
- Heartbeat `activeHours` uses configured timezone resolution.
</Accordion>
</AccordionGroup>
### Cron not firing
- Check `cron.enabled` and `OPENCLAW_SKIP_CRON` env var.
- Confirm the Gateway is running continuously.
- For `cron` schedules, verify timezone (`--tz`) vs the host timezone.
- `reason: not-due` in run output means manual run was checked with `openclaw cron run <jobId> --due` and the job was not due yet.
### Cron fired but no delivery
- Delivery mode `none` means no runner fallback send is expected. The agent can
still send directly with the `message` tool when a chat route is available.
- Delivery target missing/invalid (`channel`/`to`) means outbound was skipped.
- For Matrix, copied or legacy jobs with lowercased `delivery.to` room IDs can
fail because Matrix room IDs are case-sensitive. Edit the job to the exact
`!room:server` or `room:!room:server` value from Matrix.
- Channel auth errors (`unauthorized`, `Forbidden`) mean delivery was blocked by credentials.
- If the isolated run returns only the silent token (`NO_REPLY` / `no_reply`),
OpenClaw suppresses direct outbound delivery and also suppresses the fallback
queued summary path, so nothing is posted back to chat.
- If the agent should message the user itself, check that the job has a usable
route (`channel: "last"` with a previous chat, or an explicit channel/target).
### Cron or heartbeat appears to prevent `/new`-style rollover
- Daily and idle reset freshness is not based on `updatedAt`; see
[Session management](/concepts/session#session-lifecycle).
- Cron wakeups, heartbeat runs, exec notifications, and gateway bookkeeping may
update the session row for routing/status, but they do not extend
`sessionStartedAt` or `lastInteractionAt`.
- For legacy rows created before those fields existed, OpenClaw can recover
`sessionStartedAt` from the transcript JSONL session header when the file is
still available. Legacy idle rows without `lastInteractionAt` use that
recovered start time as their idle baseline.
### Timezone gotchas
- Cron without `--tz` uses the gateway host timezone.
- `at` schedules without timezone are treated as UTC.
- Heartbeat `activeHours` uses configured timezone resolution.
## Related

View File

@@ -3,7 +3,7 @@ summary: "Redirect to /automation"
title: "Cron vs heartbeat"
---
The decision guide for cron vs heartbeat lives under [Automation and tasks](/automation).
This page moved to [Automation & Tasks](/automation). See [Automation & Tasks](/automation) for the decision guide comparing cron and heartbeat.
## Related

View File

@@ -173,7 +173,7 @@ openclaw hooks enable <hook-name>
### session-memory details
Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `<workspace>/memory/YYYY-MM-DD-slug.md` using the host local date. Requires `workspace.dir` to be configured.
Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `<workspace>/memory/YYYY-MM-DD-slug.md`. Requires `workspace.dir` to be configured.
<a id="bootstrap-extra-files"></a>

View File

@@ -5,14 +5,12 @@ read_when:
- Debugging delivery failures for detached agent runs
- Understanding how background runs relate to sessions, cron, and heartbeat
title: "Background tasks"
sidebarTitle: "Background tasks"
---
<Note>
Looking for scheduling? See [Automation and tasks](/automation) for choosing the right mechanism. This page is the activity ledger for background work, not the scheduler.
</Note>
> **Looking for scheduling?** See [Automation & Tasks](/automation) for choosing the right mechanism. This page covers **tracking** background work, not scheduling it.
Background tasks track work that runs **outside your main conversation session**: ACP runs, subagent spawns, isolated cron job executions, and CLI-initiated operations.
Background tasks track work that runs **outside your main conversation session**:
ACP runs, subagent spawns, isolated cron job executions, and CLI-initiated operations.
Tasks do **not** replace sessions, cron jobs, or heartbeats — they are the **activity ledger** that records what detached work happened, when, and whether it succeeded.
@@ -25,68 +23,49 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
- Tasks are **records**, not schedulers — cron and heartbeat decide _when_ work runs, tasks track _what happened_.
- ACP, subagents, all cron jobs, and CLI operations create tasks. Heartbeat turns do not.
- Each task moves through `queued → running → terminal` (succeeded, failed, timed_out, cancelled, or lost).
- Cron tasks stay live while the cron runtime still owns the job; if the
in-memory runtime state is gone, task maintenance first checks durable cron
run history before marking a task lost.
- Cron tasks stay live while the cron runtime still owns the job; chat-backed CLI tasks stay live only while their owning run context is still active.
- Completion is push-driven: detached work can notify directly or wake the
requester session/heartbeat when it finishes, so status polling loops are
usually the wrong shape.
- Isolated cron runs and subagent completions best-effort clean up tracked browser tabs/processes for their child session before final cleanup bookkeeping.
- Isolated cron delivery suppresses stale interim parent replies while descendant subagent work is still draining, and it prefers final descendant output when that arrives before delivery.
- Isolated cron delivery suppresses stale interim parent replies while
descendant subagent work is still draining, and it prefers final descendant
output when that arrives before delivery.
- Completion notifications are delivered directly to a channel or queued for the next heartbeat.
- `openclaw tasks list` shows all tasks; `openclaw tasks audit` surfaces issues.
- Terminal records are kept for 7 days, then automatically pruned.
## Quick start
<Tabs>
<Tab title="List and filter">
```bash
# List all tasks (newest first)
openclaw tasks list
```bash
# List all tasks (newest first)
openclaw tasks list
# Filter by runtime or status
openclaw tasks list --runtime acp
openclaw tasks list --status running
```
# Filter by runtime or status
openclaw tasks list --runtime acp
openclaw tasks list --status running
</Tab>
<Tab title="Inspect">
```bash
# Show details for a specific task (by ID, run ID, or session key)
openclaw tasks show <lookup>
```
</Tab>
<Tab title="Cancel and notify">
```bash
# Cancel a running task (kills the child session)
openclaw tasks cancel <lookup>
# Show details for a specific task (by ID, run ID, or session key)
openclaw tasks show <lookup>
# Change notification policy for a task
openclaw tasks notify <lookup> state_changes
```
# Cancel a running task (kills the child session)
openclaw tasks cancel <lookup>
</Tab>
<Tab title="Audit and maintenance">
```bash
# Run a health audit
openclaw tasks audit
# Change notification policy for a task
openclaw tasks notify <lookup> state_changes
# Preview or apply maintenance
openclaw tasks maintenance
openclaw tasks maintenance --apply
```
# Run a health audit
openclaw tasks audit
</Tab>
<Tab title="Task flow">
```bash
# Inspect TaskFlow state
openclaw tasks flow list
openclaw tasks flow show <lookup>
openclaw tasks flow cancel <lookup>
```
</Tab>
</Tabs>
# Preview or apply maintenance
openclaw tasks maintenance
openclaw tasks maintenance --apply
# Inspect TaskFlow state
openclaw tasks flow list
openclaw tasks flow show <lookup>
openclaw tasks flow cancel <lookup>
```
## What creates a task
@@ -98,22 +77,17 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
| CLI operations | `cli` | `openclaw agent` commands that run through the gateway | `silent` |
| Agent media jobs | `cli` | Session-backed `video_generate` runs | `silent` |
<AccordionGroup>
<Accordion title="Notify defaults for cron and media">
Main-session cron tasks use `silent` notify policy by default — they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
Main-session cron tasks use `silent` notify policy by default — they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
Session-backed `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished video itself. If you opt into `tools.media.asyncCompletion.directSend`, async `music_generate` and `video_generate` completions try direct channel delivery first before falling back to the requester-session wake path.
Session-backed `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished video itself. If you opt into `tools.media.asyncCompletion.directSend`, async `music_generate` and `video_generate` completions try direct channel delivery first before falling back to the requester-session wake path.
</Accordion>
<Accordion title="Concurrent video_generate guardrail">
While a session-backed `video_generate` task is still active, the tool also acts as a guardrail: repeated `video_generate` calls in that same session return the active task status instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
</Accordion>
<Accordion title="What does not create tasks">
- Heartbeat turns — main-session; see [Heartbeat](/gateway/heartbeat)
- Normal interactive chat turns
- Direct `/command` responses
</Accordion>
</AccordionGroup>
While a session-backed `video_generate` task is still active, the tool also acts as a guardrail: repeated `video_generate` calls in that same session return the active task status instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
**What does not create tasks:**
- Heartbeat turns — main-session; see [Heartbeat](/gateway/heartbeat)
- Normal interactive chat turns
- Direct `/command` responses
## Task lifecycle
@@ -141,20 +115,12 @@ stateDiagram-v2
Transitions happen automatically — when the associated agent run ends, the task status updates to match.
Agent run completion is authoritative for active task records. A successful detached run finalizes as `succeeded`, ordinary run errors finalize as `failed`, and timeout or abort outcomes finalize as `timed_out`. If an operator already cancelled the task, or the runtime already recorded a stronger terminal state such as `failed`, `timed_out`, or `lost`, a later success signal does not downgrade that terminal status.
`lost` is runtime-aware:
- ACP tasks: backing ACP child session metadata disappeared.
- Subagent tasks: backing child session disappeared from the target agent store.
- Cron tasks: the cron runtime no longer tracks the job as active and durable
cron run history does not show a terminal result for that run. Offline CLI
audit does not treat its own empty in-process cron runtime state as authority.
- CLI tasks: isolated child-session tasks use the child session; chat-backed
CLI tasks use the live run context instead, so lingering
channel/group/direct session rows do not keep them alive. Gateway-backed
`openclaw agent` runs also finalize from their run result, so completed runs
do not sit active until the sweeper marks them `lost`.
- Cron tasks: the cron runtime no longer tracks the job as active.
- CLI tasks: isolated child-session tasks use the child session; chat-backed CLI tasks use the live run context instead, so lingering channel/group/direct session rows do not keep them alive.
## Delivery and notifications
@@ -168,7 +134,9 @@ When a task reaches a terminal state, OpenClaw notifies you. There are two deliv
Task completion triggers an immediate heartbeat wake so you see the result quickly — you do not have to wait for the next scheduled heartbeat tick.
</Tip>
That means the usual workflow is push-based: start detached work once, then let the runtime wake or notify you on completion. Poll task state only when you need debugging, intervention, or an explicit audit.
That means the usual workflow is push-based: start detached work once, then let
the runtime wake or notify you on completion. Poll task state only when you
need debugging, intervention, or an explicit audit.
### Notification policies
@@ -188,93 +156,96 @@ openclaw tasks notify <lookup> state_changes
## CLI reference
<AccordionGroup>
<Accordion title="tasks list">
```bash
openclaw tasks list [--runtime <acp|subagent|cron|cli>] [--status <status>] [--json]
```
### `tasks list`
Output columns: Task ID, Kind, Status, Delivery, Run ID, Child Session, Summary.
```bash
openclaw tasks list [--runtime <acp|subagent|cron|cli>] [--status <status>] [--json]
```
</Accordion>
<Accordion title="tasks show">
```bash
openclaw tasks show <lookup>
```
Output columns: Task ID, Kind, Status, Delivery, Run ID, Child Session, Summary.
The lookup token accepts a task ID, run ID, or session key. Shows the full record including timing, delivery state, error, and terminal summary.
### `tasks show`
</Accordion>
<Accordion title="tasks cancel">
```bash
openclaw tasks cancel <lookup>
```
```bash
openclaw tasks show <lookup>
```
For ACP and subagent tasks, this kills the child session. For CLI-tracked tasks, cancellation is recorded in the task registry (there is no separate child runtime handle). Status transitions to `cancelled` and a delivery notification is sent when applicable.
The lookup token accepts a task ID, run ID, or session key. Shows the full record including timing, delivery state, error, and terminal summary.
</Accordion>
<Accordion title="tasks notify">
```bash
openclaw tasks notify <lookup> <done_only|state_changes|silent>
```
</Accordion>
<Accordion title="tasks audit">
```bash
openclaw tasks audit [--json]
```
### `tasks cancel`
Surfaces operational issues. Findings also appear in `openclaw status` when issues are detected.
```bash
openclaw tasks cancel <lookup>
```
| Finding | Severity | Trigger |
| ------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| `stale_queued` | warn | Queued for more than 10 minutes |
| `stale_running` | error | Running for more than 30 minutes |
| `lost` | warn/error | Runtime-backed task ownership disappeared; retained lost tasks warn until `cleanupAfter`, then become errors |
| `delivery_failed` | warn | Delivery failed and notify policy is not `silent` |
| `missing_cleanup` | warn | Terminal task with no cleanup timestamp |
| `inconsistent_timestamps` | warn | Timeline violation (for example ended before started) |
For ACP and subagent tasks, this kills the child session. For CLI-tracked tasks, cancellation is recorded in the task registry (there is no separate child runtime handle). Status transitions to `cancelled` and a delivery notification is sent when applicable.
</Accordion>
<Accordion title="tasks maintenance">
```bash
openclaw tasks maintenance [--json]
openclaw tasks maintenance --apply [--json]
```
### `tasks notify`
Use this to preview or apply reconciliation, cleanup stamping, and pruning for tasks and Task Flow state.
```bash
openclaw tasks notify <lookup> <done_only|state_changes|silent>
```
Reconciliation is runtime-aware:
### `tasks audit`
- ACP/subagent tasks check their backing child session.
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
```bash
openclaw tasks audit [--json]
```
Completion cleanup is also runtime-aware:
Surfaces operational issues. Findings also appear in `openclaw status` when issues are detected.
- Subagent completion best-effort closes tracked browser tabs/processes for the child session before announce cleanup continues.
- Isolated cron completion best-effort closes tracked browser tabs/processes for the cron session before the run fully tears down.
- Isolated cron delivery waits out descendant subagent follow-up when needed and suppresses stale parent acknowledgement text instead of announcing it.
- Subagent completion delivery prefers the latest visible assistant text; if that is empty it falls back to sanitized latest tool/toolResult text, and timeout-only tool-call runs can collapse to a short partial-progress summary. Terminal failed runs announce failure status without replaying captured reply text.
- Cleanup failures do not mask the real task outcome.
| Finding | Severity | Trigger |
| ------------------------- | -------- | ----------------------------------------------------- |
| `stale_queued` | warn | Queued for more than 10 minutes |
| `stale_running` | error | Running for more than 30 minutes |
| `lost` | error | Runtime-backed task ownership disappeared |
| `delivery_failed` | warn | Delivery failed and notify policy is not `silent` |
| `missing_cleanup` | warn | Terminal task with no cleanup timestamp |
| `inconsistent_timestamps` | warn | Timeline violation (for example ended before started) |
</Accordion>
<Accordion title="tasks flow list | show | cancel">
```bash
openclaw tasks flow list [--status <status>] [--json]
openclaw tasks flow show <lookup> [--json]
openclaw tasks flow cancel <lookup>
```
### `tasks maintenance`
Use these when the orchestrating Task Flow is the thing you care about rather than one individual background task record.
```bash
openclaw tasks maintenance [--json]
openclaw tasks maintenance --apply [--json]
```
</Accordion>
</AccordionGroup>
Use this to preview or apply reconciliation, cleanup stamping, and pruning for
tasks and Task Flow state.
Reconciliation is runtime-aware:
- ACP/subagent tasks check their backing child session.
- Cron tasks check whether the cron runtime still owns the job.
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
Completion cleanup is also runtime-aware:
- Subagent completion best-effort closes tracked browser tabs/processes for the child session before announce cleanup continues.
- Isolated cron completion best-effort closes tracked browser tabs/processes for the cron session before the run fully tears down.
- Isolated cron delivery waits out descendant subagent follow-up when needed and
suppresses stale parent acknowledgement text instead of announcing it.
- Subagent completion delivery prefers the latest visible assistant text; if that is empty it falls back to sanitized latest tool/toolResult text, and timeout-only tool-call runs can collapse to a short partial-progress summary. Terminal failed runs announce failure status without replaying captured reply text.
- Cleanup failures do not mask the real task outcome.
### `tasks flow list|show|cancel`
```bash
openclaw tasks flow list [--status <status>] [--json]
openclaw tasks flow show <lookup> [--json]
openclaw tasks flow cancel <lookup>
```
Use these when the orchestrating Task Flow is the thing you care about rather
than one individual background task record.
## Chat task board (`/tasks`)
Use `/tasks` in any chat session to see background tasks linked to that session. The board shows active and recently completed tasks with runtime, status, timing, and progress or error detail.
Use `/tasks` in any chat session to see background tasks linked to that session. The board shows
active and recently completed tasks with runtime, status, timing, and progress or error detail.
When the current session has no visible linked tasks, `/tasks` falls back to agent-local task counts so you still get an overview without leaking other-session details.
When the current session has no visible linked tasks, `/tasks` falls back to agent-local task counts
so you still get an overview without leaking other-session details.
For the full operator ledger, use the CLI: `openclaw tasks list`.
@@ -292,7 +263,9 @@ The summary reports:
- **failures** — count of `failed` + `timed_out` + `lost`
- **byRuntime** — breakdown by `acp`, `subagent`, `cron`, `cli`
Both `/status` and the `session_status` tool use a cleanup-aware task snapshot: active tasks are preferred, stale completed rows are hidden, and recent failures only surface when no active work remains. This keeps the status card focused on what matters right now.
Both `/status` and the `session_status` tool use a cleanup-aware task snapshot: active tasks are
preferred, stale completed rows are hidden, and recent failures only surface when no active work
remains. This keeps the status card focused on what matters right now.
## Storage and maintenance
@@ -310,55 +283,44 @@ The registry loads into memory at gateway start and syncs writes to SQLite for d
A sweeper runs every **60 seconds** and handles three things:
<Steps>
<Step title="Reconciliation">
Checks whether active tasks still have authoritative runtime backing. ACP/subagent tasks use child-session state, cron tasks use active-job ownership, and chat-backed CLI tasks use the owning run context. If that backing state is gone for more than 5 minutes, the task is marked `lost`.
</Step>
<Step title="Cleanup stamping">
Sets a `cleanupAfter` timestamp on terminal tasks (endedAt + 7 days). During retention, lost tasks still appear in audit as warnings; after `cleanupAfter` expires or when cleanup metadata is missing, they are errors.
</Step>
<Step title="Pruning">
Deletes records past their `cleanupAfter` date.
</Step>
</Steps>
1. **Reconciliation** — checks whether active tasks still have authoritative runtime backing. ACP/subagent tasks use child-session state, cron tasks use active-job ownership, and chat-backed CLI tasks use the owning run context. If that backing state is gone for more than 5 minutes, the task is marked `lost`.
2. **Cleanup stamping** — sets a `cleanupAfter` timestamp on terminal tasks (endedAt + 7 days).
3. **Pruning** — deletes records past their `cleanupAfter` date.
<Note>
**Retention:** terminal task records are kept for **7 days**, then automatically pruned. No configuration needed.
</Note>
**Retention**: terminal task records are kept for **7 days**, then automatically pruned. No configuration needed.
## How tasks relate to other systems
<AccordionGroup>
<Accordion title="Tasks and Task Flow">
[Task Flow](/automation/taskflow) is the flow orchestration layer above background tasks. A single flow may coordinate multiple tasks over its lifetime using managed or mirrored sync modes. Use `openclaw tasks` to inspect individual task records and `openclaw tasks flow` to inspect the orchestrating flow.
### Tasks and Task Flow
See [Task Flow](/automation/taskflow) for details.
[Task Flow](/automation/taskflow) is the flow orchestration layer above background tasks. A single flow may coordinate multiple tasks over its lifetime using managed or mirrored sync modes. Use `openclaw tasks` to inspect individual task records and `openclaw tasks flow` to inspect the orchestrating flow.
</Accordion>
<Accordion title="Tasks and cron">
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record — both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
See [Task Flow](/automation/taskflow) for details.
See [Cron Jobs](/automation/cron-jobs).
### Tasks and cron
</Accordion>
<Accordion title="Tasks and heartbeat">
Heartbeat runs are main-session turns — they do not create task records. When a task completes, it can trigger a heartbeat wake so you see the result promptly.
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record — both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
See [Heartbeat](/gateway/heartbeat).
See [Cron Jobs](/automation/cron-jobs).
</Accordion>
<Accordion title="Tasks and sessions">
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
</Accordion>
<Accordion title="Tasks and agent runs">
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status — you do not need to manage the lifecycle manually.
</Accordion>
</AccordionGroup>
### Tasks and heartbeat
Heartbeat runs are main-session turns — they do not create task records. When a task completes, it can trigger a heartbeat wake so you see the result promptly.
See [Heartbeat](/gateway/heartbeat).
### Tasks and sessions
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
### Tasks and agent runs
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status — you do not need to manage the lifecycle manually.
## Related
- [Automation & Tasks](/automation) — all automation mechanisms at a glance
- [CLI: Tasks](/cli/tasks) — CLI command reference
- [Heartbeat](/gateway/heartbeat) — periodic main-session turns
- [Scheduled Tasks](/automation/cron-jobs) — scheduling background work
- [Task Flow](/automation/taskflow) — flow orchestration above tasks
- [Scheduled Tasks](/automation/cron-jobs) — scheduling background work
- [Heartbeat](/gateway/heartbeat) — periodic main-session turns
- [CLI: Tasks](/cli/tasks) — CLI command reference

View File

@@ -5,14 +5,14 @@ read_when:
- Troubleshooting webhook pairing
- Configuring iMessage on macOS
title: "BlueBubbles"
sidebarTitle: "BlueBubbles"
---
Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel.
<Note>
Current OpenClaw releases bundle BlueBubbles, so normal packaged builds do not need a separate `openclaw plugins install` step.
</Note>
## Bundled plugin
Current OpenClaw releases bundle BlueBubbles, so normal packaged builds do not
need a separate `openclaw plugins install` step.
## Overview
@@ -21,119 +21,111 @@ Current OpenClaw releases bundle BlueBubbles, so normal packaged builds do not n
- OpenClaw talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Auto-TTS replies that synthesize MP3 or CAF audio are delivered as iMessage voice memo bubbles instead of plain file attachments.
- Pairing/allowlist works the same way as other channels (`/channels/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying.
- Advanced features: edit, unsend, reply threading, message effects, group management.
## Quick start
<Steps>
<Step title="Install BlueBubbles">
Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
</Step>
<Step title="Enable the web API">
In the BlueBubbles config, enable the web API and set a password.
</Step>
<Step title="Configure OpenClaw">
Run `openclaw onboard` and select BlueBubbles, or configure manually:
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
2. In the BlueBubbles config, enable the web API and set a password.
3. Run `openclaw onboard` and select BlueBubbles, or configure manually:
```json5
{
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://192.168.1.100:1234",
password: "example-password",
webhookPath: "/bluebubbles-webhook",
},
},
}
```
```json5
{
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://192.168.1.100:1234",
password: "example-password",
webhookPath: "/bluebubbles-webhook",
},
},
}
```
</Step>
<Step title="Point webhooks at the gateway">
Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
</Step>
<Step title="Start the gateway">
Start the gateway; it will register the webhook handler and start pairing.
</Step>
</Steps>
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
<Warning>
**Security**
Security note:
- Always set a webhook password.
- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=<password>` or `x-password`), regardless of loopback/proxy topology.
- Password authentication is checked before reading/parsing full webhook bodies.
</Warning>
## Keeping Messages.app alive (VM / headless setups)
Some macOS VM / always-on setups can end up with Messages.app going "idle" (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
Some macOS VM / always-on setups can end up with Messages.app going idle (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
<Steps>
<Step title="Save the AppleScript">
Save this as `~/Scripts/poke-messages.scpt`:
### 1) Save the AppleScript
```applescript
try
tell application "Messages"
if not running then
launch
end if
Save this as:
-- Touch the scripting interface to keep the process responsive.
set _chatCount to (count of chats)
end tell
on error
-- Ignore transient failures (first-run prompts, locked session, etc).
end try
```
- `~/Scripts/poke-messages.scpt`
</Step>
<Step title="Install a LaunchAgent">
Save this as `~/Library/LaunchAgents/com.user.poke-messages.plist`:
Example script (non-interactive; does not steal focus):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.poke-messages</string>
```applescript
try
tell application "Messages"
if not running then
launch
end if
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>/usr/bin/osascript &quot;$HOME/Scripts/poke-messages.scpt&quot;</string>
</array>
-- Touch the scripting interface to keep the process responsive.
set _chatCount to (count of chats)
end tell
on error
-- Ignore transient failures (first-run prompts, locked session, etc).
end try
```
<key>RunAtLoad</key>
<true/>
### 2) Install a LaunchAgent
<key>StartInterval</key>
<integer>300</integer>
Save this as:
<key>StandardOutPath</key>
<string>/tmp/poke-messages.log</string>
<key>StandardErrorPath</key>
<string>/tmp/poke-messages.err</string>
</dict>
</plist>
```
- `~/Library/LaunchAgents/com.user.poke-messages.plist`
This runs **every 300 seconds** and **on login**. The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.poke-messages</string>
</Step>
<Step title="Load it">
```bash
launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
```
</Step>
</Steps>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>/usr/bin/osascript &quot;$HOME/Scripts/poke-messages.scpt&quot;</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/tmp/poke-messages.log</string>
<key>StandardErrorPath</key>
<string>/tmp/poke-messages.err</string>
</dict>
</plist>
```
Notes:
- This runs **every 300 seconds** and **on login**.
- The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent.
Load it:
```bash
launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
```
## Onboarding
@@ -145,21 +137,11 @@ openclaw onboard
The wizard prompts for:
<ParamField path="Server URL" type="string" required>
BlueBubbles server address (e.g., `http://192.168.1.100:1234`).
</ParamField>
<ParamField path="Password" type="string" required>
API password from BlueBubbles Server settings.
</ParamField>
<ParamField path="Webhook path" type="string" default="/bluebubbles-webhook">
Webhook endpoint path.
</ParamField>
<ParamField path="DM policy" type="string">
`pairing`, `allowlist`, `open`, or `disabled`.
</ParamField>
<ParamField path="Allow list" type="string[]">
Phone numbers, emails, or chat targets.
</ParamField>
- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`)
- **Password** (required): API password from BlueBubbles Server settings
- **Webhook path** (optional): Defaults to `/bluebubbles-webhook`
- **DM policy**: pairing, allowlist, open, or disabled
- **Allow list**: Phone numbers, emails, or chat targets
You can also add BlueBubbles via CLI:
@@ -169,20 +151,19 @@ openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --passwor
## Access control (DMs + groups)
<Tabs>
<Tab title="DMs">
- Default: `channels.bluebubbles.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `openclaw pairing list bluebubbles`
- `openclaw pairing approve bluebubbles <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/channels/pairing)
</Tab>
<Tab title="Groups">
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
</Tab>
</Tabs>
DMs:
- Default: `channels.bluebubbles.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `openclaw pairing list bluebubbles`
- `openclaw pairing approve bluebubbles <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/channels/pairing)
Groups:
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
### Contact name enrichment (macOS, optional)
@@ -378,23 +359,21 @@ BlueBubbles supports advanced message actions when enabled in config:
}
```
<AccordionGroup>
<Accordion title="Available actions">
- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`). iMessage's native tapback set is `love`, `like`, `dislike`, `laugh`, `emphasize`, and `question`. When an agent picks an emoji outside that set (for example `👀`), the reaction tool falls back to `love` so the tapback still renders instead of failing the whole request. Configured ack reactions still validate strictly and error on unknown values.
- **edit**: Edit a sent message (`messageId`, `text`).
- **unsend**: Unsend a message (`messageId`).
- **reply**: Reply to a specific message (`messageId`, `text`, `to`).
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`).
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`).
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync).
- **addParticipant**: Add someone to a group (`chatGuid`, `address`).
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`).
- **leaveGroup**: Leave a group chat (`chatGuid`).
- **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`).
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
- Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name.
</Accordion>
</AccordionGroup>
Available actions:
- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`). iMessage's native tapback set is `love`, `like`, `dislike`, `laugh`, `emphasize`, and `question`. When an agent picks an emoji outside that set (for example `👀`), the reaction tool falls back to `love` so the tapback still renders instead of failing the whole request. Configured ack reactions still validate strictly and error on unknown values.
- **edit**: Edit a sent message (`messageId`, `text`)
- **unsend**: Unsend a message (`messageId`)
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync).
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
- Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name.
### Message IDs (short vs full)
@@ -425,56 +404,54 @@ The two webhooks arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coa
`channels.bluebubbles.coalesceSameSenderDms` opts a DM into merging consecutive same-sender webhooks into a single agent turn. Group chats continue to key per-message so multi-user turn structure is preserved.
<Tabs>
<Tab title="When to enable">
Enable when:
### When to enable
- You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.).
- Your users paste URLs, images, or long content alongside commands.
- You can accept the added DM turn latency (see below).
Enable when:
Leave disabled when:
- You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.).
- Your users paste URLs, images, or long content alongside commands.
- You can accept the added DM turn latency (see below).
- You need minimum command latency for single-word DM triggers.
- All your flows are one-shot commands without payload follow-ups.
Leave disabled when:
</Tab>
<Tab title="Enabling">
```json5
{
channels: {
bluebubbles: {
coalesceSameSenderDms: true, // opt in (default: false)
},
- You need minimum command latency for single-word DM triggers.
- All your flows are one-shot commands without payload follow-ups.
### Enabling
```json5
{
channels: {
bluebubbles: {
coalesceSameSenderDms: true, // opt in (default: false)
},
},
}
```
With the flag on and no explicit `messages.inbound.byChannel.bluebubbles`, the debounce window widens to **2500 ms** (the default for non-coalescing is 500 ms). The wider window is required — Apple's split-send cadence of 0.8-2.0 s does not fit in the tighter default.
To tune the window yourself:
```json5
{
messages: {
inbound: {
byChannel: {
// 2500 ms works for most setups; raise to 4000 ms if your Mac is slow
// or under memory pressure (observed gap can stretch past 2 s then).
bluebubbles: 2500,
},
}
```
},
},
}
```
With the flag on and no explicit `messages.inbound.byChannel.bluebubbles`, the debounce window widens to **2500 ms** (the default for non-coalescing is 500 ms). The wider window is required — Apple's split-send cadence of 0.8-2.0 s does not fit in the tighter default.
### Trade-offs
To tune the window yourself:
```json5
{
messages: {
inbound: {
byChannel: {
// 2500 ms works for most setups; raise to 4000 ms if your Mac is slow
// or under memory pressure (observed gap can stretch past 2 s then).
bluebubbles: 2500,
},
},
},
}
```
</Tab>
<Tab title="Trade-offs">
- **Added latency for DM control commands.** With the flag on, DM control-command messages (like `Dump`, `Save`, etc.) now wait up to the debounce window before dispatching, in case a payload webhook is coming. Group-chat commands keep instant dispatch.
- **Merged output is bounded** — merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source `messageId` still reaches inbound-dedupe so a later MessagePoller replay of any individual event is recognized as a duplicate.
- **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected.
</Tab>
</Tabs>
- **Added latency for DM control commands.** With the flag on, DM control-command messages (like `Dump`, `Save`, etc.) now wait up to the debounce window before dispatching, in case a payload webhook is coming. Group-chat commands keep instant dispatch.
- **Merged output is bounded** — merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source `messageId` still reaches inbound-dedupe so a later MessagePoller replay of any individual event is recognized as a duplicate.
- **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected.
### Scenarios and what the agent sees
@@ -491,35 +468,27 @@ The two webhooks arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coa
If the flag is on and split-sends still arrive as two turns, check each layer:
<AccordionGroup>
<Accordion title="Config actually loaded">
```
grep coalesceSameSenderDms ~/.openclaw/openclaw.json
```
1. **Config actually loaded.**
Then `openclaw gateway restart` — the flag is read at debouncer-registry creation.
```
grep coalesceSameSenderDms ~/.openclaw/openclaw.json
```
</Accordion>
<Accordion title="Debounce window wide enough for your setup">
Look at the BlueBubbles server log under `~/Library/Logs/bluebubbles-server/main.log`:
Then `openclaw gateway restart` — the flag is read at debouncer-registry creation.
```
grep -E "Dispatching event to webhook" main.log | tail -20
```
2. **Debounce window wide enough for your setup.** Look at the BlueBubbles server log under `~/Library/Logs/bluebubbles-server/main.log`:
Measure the gap between the `"Dump"`-style text dispatch and the `"https://..."; Attachments:` dispatch that follows. Raise `messages.inbound.byChannel.bluebubbles` to comfortably cover that gap.
```
grep -E "Dispatching event to webhook" main.log | tail -20
```
</Accordion>
<Accordion title="Session JSONL timestamps ≠ webhook arrival">
Session event timestamps (`~/.openclaw/agents/<id>/sessions/*.jsonl`) reflect when the gateway hands a message to the agent, **not** when the webhook arrived. A queued-second message tagged `[Queued messages while agent was busy]` means the first turn was still running when the second webhook arrived — the coalesce bucket had already flushed. Tune the window against the BB server log, not the session log.
</Accordion>
<Accordion title="Memory pressure slowing reply dispatch">
On smaller machines (8 GB), agent turns can take long enough that the coalesce bucket flushes before the reply completes, and the URL lands as a queued second turn. Check `memory_pressure` and `ps -o rss -p $(pgrep openclaw-gateway)`; if the gateway is over ~500 MB RSS and the compressor is active, close other heavy processes or bump to a larger host.
</Accordion>
<Accordion title="Reply-quote sends are a different path">
If the user tapped `Dump` as a **reply** to an existing URL-balloon (iMessage shows a "1 Reply" badge on the Dump bubble), the URL lives in `replyToBody`, not in a second webhook. Coalescing does not apply — that's a skill/prompt concern, not a debouncer concern.
</Accordion>
</AccordionGroup>
Measure the gap between the `"Dump"`-style text dispatch and the `"https://..."; Attachments:` dispatch that follows. Raise `messages.inbound.byChannel.bluebubbles` to comfortably cover that gap.
3. **Session JSONL timestamps ≠ webhook arrival.** Session event timestamps (`~/.openclaw/agents/<id>/sessions/*.jsonl`) reflect when the gateway hands a message to the agent, **not** when the webhook arrived. A queued-second message tagged `[Queued messages while agent was busy]` means the first turn was still running when the second webhook arrived — the coalesce bucket had already flushed. Tune the window against the BB server log, not the session log.
4. **Memory pressure slowing reply dispatch.** On smaller machines (8 GB), agent turns can take long enough that the coalesce bucket flushes before the reply completes, and the URL lands as a queued second turn. Check `memory_pressure` and `ps -o rss -p $(pgrep openclaw-gateway)`; if the gateway is over ~500 MB RSS and the compressor is active, close other heavy processes or bump to a larger host.
5. **Reply-quote sends are a different path.** If the user tapped `Dump` as a **reply** to an existing URL-balloon (iMessage shows a "1 Reply" badge on the Dump bubble), the URL lives in `replyToBody`, not in a second webhook. Coalescing does not apply — that's a skill/prompt concern, not a debouncer concern.
## Block streaming
@@ -545,40 +514,30 @@ Control whether responses are sent as a single message or streamed in blocks:
Full configuration: [Configuration](/gateway/configuration)
<AccordionGroup>
<Accordion title="Connection and webhook">
- `channels.bluebubbles.enabled`: Enable/disable the channel.
- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL.
- `channels.bluebubbles.password`: API password.
- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`).
</Accordion>
<Accordion title="Access policy">
- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`).
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
- `channels.bluebubbles.enrichGroupParticipantsFromContacts`: On macOS, optionally enrich unnamed group participants from local Contacts after gating passes. Default: `false`.
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
</Accordion>
<Accordion title="Delivery and chunking">
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.sendTimeoutMs`: Per-request timeout in ms for outbound text sends via `/api/v1/message/text` (default: 30000). Raise on macOS 26 setups where Private API iMessage sends can stall for 60+ seconds inside the iMessage framework; for example `45000` or `60000`. Probes, chat lookups, reactions, edits, and health checks currently keep the shorter 10s default; broadening coverage to reactions and edits is planned as a follow-up. Per-account override: `channels.bluebubbles.accounts.<accountId>.sendTimeoutMs`.
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
</Accordion>
<Accordion title="Media and history">
- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8).
- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts.<accountId>.mediaLocalRoots`.
- `channels.bluebubbles.coalesceSameSenderDms`: Merge consecutive same-sender DM webhooks into one agent turn so Apple's text+URL split-send arrives as a single message (default: `false`). See [Coalescing split-send DMs](#coalescing-split-send-dms-command--url-in-one-composition) for scenarios, window tuning, and trade-offs. Widens the default inbound debounce window from 500 ms to 2500 ms when enabled without an explicit `messages.inbound.byChannel.bluebubbles`.
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
</Accordion>
<Accordion title="Actions and accounts">
- `channels.bluebubbles.actions`: Enable/disable specific actions.
- `channels.bluebubbles.accounts`: Multi-account configuration.
</Accordion>
</AccordionGroup>
Provider options:
- `channels.bluebubbles.enabled`: Enable/disable the channel.
- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL.
- `channels.bluebubbles.password`: API password.
- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`).
- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`).
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
- `channels.bluebubbles.enrichGroupParticipantsFromContacts`: On macOS, optionally enrich unnamed group participants from local Contacts after gating passes. Default: `false`.
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.sendTimeoutMs`: Per-request timeout in ms for outbound text sends via `/api/v1/message/text` (default: 30000). Raise on macOS 26 setups where Private API iMessage sends can stall for 60+ seconds inside the iMessage framework; for example `45000` or `60000`. Probes, chat lookups, reactions, edits, and health checks currently keep the shorter 10s default; broadening coverage to reactions and edits is planned as a follow-up. Per-account override: `channels.bluebubbles.accounts.<accountId>.sendTimeoutMs`.
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8).
- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts.<accountId>.mediaLocalRoots`.
- `channels.bluebubbles.coalesceSameSenderDms`: Merge consecutive same-sender DM webhooks into one agent turn so Apple's text+URL split-send arrives as a single message (default: `false`). See [Coalescing split-send DMs](#coalescing-split-send-dms-command--url-in-one-composition) for scenarios, window tuning, and trade-offs. Widens the default inbound debounce window from 500 ms to 2500 ms when enabled without an explicit `messages.inbound.byChannel.bluebubbles`.
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
- `channels.bluebubbles.actions`: Enable/disable specific actions.
- `channels.bluebubbles.accounts`: Multi-account configuration.
Related global options:
@@ -621,8 +580,8 @@ For general channel workflow reference, see [Channels](/channels) and the [Plugi
## Related
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Channels Overview](/channels) — all supported channels
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Pairing](/channels/pairing) — DM authentication and pairing flow
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Security](/gateway/security) — access model and hardening

View File

@@ -5,12 +5,10 @@ read_when:
- Debugging multi-agent replies in WhatsApp
status: experimental
title: "Broadcast groups"
sidebarTitle: "Broadcast groups"
---
<Note>
**Status:** Experimental. Added in 2026.1.9.
</Note>
**Status:** Experimental
**Version:** Added in 2026.1.9
## Overview
@@ -20,55 +18,55 @@ Current scope: **WhatsApp only** (web channel).
Broadcast groups are evaluated after channel allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when OpenClaw would normally reply (for example: on mention, depending on your group settings).
## Use cases
## Use Cases
<AccordionGroup>
<Accordion title="1. Specialized agent teams">
Deploy multiple agents with atomic, focused responsibilities:
### 1. Specialized Agent Teams
```
Group: "Development Team"
Agents:
- CodeReviewer (reviews code snippets)
- DocumentationBot (generates docs)
- SecurityAuditor (checks for vulnerabilities)
- TestGenerator (suggests test cases)
```
Deploy multiple agents with atomic, focused responsibilities:
Each agent processes the same message and provides its specialized perspective.
```
Group: "Development Team"
Agents:
- CodeReviewer (reviews code snippets)
- DocumentationBot (generates docs)
- SecurityAuditor (checks for vulnerabilities)
- TestGenerator (suggests test cases)
```
</Accordion>
<Accordion title="2. Multi-language support">
```
Group: "International Support"
Agents:
- Agent_EN (responds in English)
- Agent_DE (responds in German)
- Agent_ES (responds in Spanish)
```
</Accordion>
<Accordion title="3. Quality assurance workflows">
```
Group: "Customer Support"
Agents:
- SupportAgent (provides answer)
- QAAgent (reviews quality, only responds if issues found)
```
</Accordion>
<Accordion title="4. Task automation">
```
Group: "Project Management"
Agents:
- TaskTracker (updates task database)
- TimeLogger (logs time spent)
- ReportGenerator (creates summaries)
```
</Accordion>
</AccordionGroup>
Each agent processes the same message and provides its specialized perspective.
### 2. Multi-Language Support
```
Group: "International Support"
Agents:
- Agent_EN (responds in English)
- Agent_DE (responds in German)
- Agent_ES (responds in Spanish)
```
### 3. Quality Assurance Workflows
```
Group: "Customer Support"
Agents:
- SupportAgent (provides answer)
- QAAgent (reviews quality, only responds if issues found)
```
### 4. Task Automation
```
Group: "Project Management"
Agents:
- TaskTracker (updates task database)
- TimeLogger (logs time spent)
- ReportGenerator (creates summaries)
```
## Configuration
### Basic setup
### Basic Setup
Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids:
@@ -85,40 +83,37 @@ Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer
**Result:** When OpenClaw would reply in this chat, it will run all three agents.
### Processing strategy
### Processing Strategy
Control how agents process messages:
<Tabs>
<Tab title="parallel (default)">
All agents process simultaneously:
#### Parallel (Default)
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
All agents process simultaneously:
</Tab>
<Tab title="sequential">
Agents process in order (one waits for previous to finish):
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
```json
{
"broadcast": {
"strategy": "sequential",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
#### Sequential
</Tab>
</Tabs>
Agents process in order (one waits for previous to finish):
### Complete example
```json
{
"broadcast": {
"strategy": "sequential",
"120363403215116621@g.us": ["alfred", "baerbel"]
}
}
```
### Complete Example
```json
{
@@ -153,32 +148,22 @@ Control how agents process messages:
}
```
## How it works
## How It Works
### Message flow
### Message Flow
<Steps>
<Step title="Incoming message arrives">
A WhatsApp group or DM message arrives.
</Step>
<Step title="Broadcast check">
System checks if peer ID is in `broadcast`.
</Step>
<Step title="If in broadcast list">
- All listed agents process the message.
- Each agent has its own session key and isolated context.
- Agents process in parallel (default) or sequentially.
</Step>
<Step title="If not in broadcast list">
Normal routing applies (first matching binding).
</Step>
</Steps>
1. **Incoming message** arrives in a WhatsApp group
2. **Broadcast check**: System checks if peer ID is in `broadcast`
3. **If in broadcast list**:
- All listed agents process the message
- Each agent has its own session key and isolated context
- Agents process in parallel (default) or sequentially
4. **If not in broadcast list**:
- Normal routing applies (first matching binding)
<Note>
Broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing.
</Note>
Note: broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing.
### Session isolation
### Session Isolation
Each agent in a broadcast group maintains completely separate:
@@ -196,95 +181,92 @@ This allows each agent to have:
- Different models (e.g., opus vs. sonnet)
- Different skills installed
### Example: isolated sessions
### Example: Isolated Sessions
In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
<Tabs>
<Tab title="Alfred's context">
```
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
History: [user message, alfred's previous responses]
Workspace: /Users/user/openclaw-alfred/
Tools: read, write, exec
```
</Tab>
<Tab title="Bärbel's context">
```
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
History: [user message, baerbel's previous responses]
Workspace: /Users/user/openclaw-baerbel/
Tools: read only
```
</Tab>
</Tabs>
**Alfred's context:**
## Best practices
```
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
History: [user message, alfred's previous responses]
Workspace: /Users/user/openclaw-alfred/
Tools: read, write, exec
```
<AccordionGroup>
<Accordion title="1. Keep agents focused">
Design each agent with a single, clear responsibility:
**Bärbel's context:**
```json
{
"broadcast": {
"DEV_GROUP": ["formatter", "linter", "tester"]
}
```
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
History: [user message, baerbel's previous responses]
Workspace: /Users/user/openclaw-baerbel/
Tools: read only
```
## Best Practices
### 1. Keep Agents Focused
Design each agent with a single, clear responsibility:
```json
{
"broadcast": {
"DEV_GROUP": ["formatter", "linter", "tester"]
}
}
```
**Good:** Each agent has one job
**Bad:** One generic "dev-helper" agent
### 2. Use Descriptive Names
Make it clear what each agent does:
```json
{
"agents": {
"security-scanner": { "name": "Security Scanner" },
"code-formatter": { "name": "Code Formatter" },
"test-generator": { "name": "Test Generator" }
}
}
```
### 3. Configure Different Tool Access
Give agents only the tools they need:
```json
{
"agents": {
"reviewer": {
"tools": { "allow": ["read", "exec"] } // Read-only
},
"fixer": {
"tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write
}
```
}
}
```
✅ **Good:** Each agent has one job. ❌ **Bad:** One generic "dev-helper" agent.
### 4. Monitor Performance
</Accordion>
<Accordion title="2. Use descriptive names">
Make it clear what each agent does:
With many agents, consider:
```json
{
"agents": {
"security-scanner": { "name": "Security Scanner" },
"code-formatter": { "name": "Code Formatter" },
"test-generator": { "name": "Test Generator" }
}
}
```
- Using `"strategy": "parallel"` (default) for speed
- Limiting broadcast groups to 5-10 agents
- Using faster models for simpler agents
</Accordion>
<Accordion title="3. Configure different tool access">
Give agents only the tools they need:
### 5. Handle Failures Gracefully
```json
{
"agents": {
"reviewer": {
"tools": { "allow": ["read", "exec"] } // Read-only
},
"fixer": {
"tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write
}
}
}
```
Agents fail independently. One agent's error doesn't block others:
</Accordion>
<Accordion title="4. Monitor performance">
With many agents, consider:
- Using `"strategy": "parallel"` (default) for speed
- Limiting broadcast groups to 5-10 agents
- Using faster models for simpler agents
</Accordion>
<Accordion title="5. Handle failures gracefully">
Agents fail independently. One agent's error doesn't block others:
```
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
Result: Agent A and C respond, Agent B logs error
```
</Accordion>
</AccordionGroup>
```
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
Result: Agent A and C respond, Agent B logs error
```
## Compatibility
@@ -315,116 +297,108 @@ Broadcast groups work alongside existing routing:
}
```
- `GROUP_A`: Only alfred responds (normal routing).
- `GROUP_B`: agent1 AND agent2 respond (broadcast).
- `GROUP_A`: Only alfred responds (normal routing)
- `GROUP_B`: agent1 AND agent2 respond (broadcast)
<Note>
**Precedence:** `broadcast` takes priority over `bindings`.
</Note>
## Troubleshooting
<AccordionGroup>
<Accordion title="Agents not responding">
**Check:**
### Agents Not Responding
1. Agent IDs exist in `agents.list`.
2. Peer ID format is correct (e.g., `120363403215116621@g.us`).
3. Agents are not in deny lists.
**Check:**
**Debug:**
1. Agent IDs exist in `agents.list`
2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
3. Agents are not in deny lists
```bash
tail -f ~/.openclaw/logs/gateway.log | grep broadcast
```
**Debug:**
</Accordion>
<Accordion title="Only one agent responding">
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
```bash
tail -f ~/.openclaw/logs/gateway.log | grep broadcast
```
**Fix:** Add to broadcast config or remove from bindings.
### Only One Agent Responding
</Accordion>
<Accordion title="Performance issues">
If slow with many agents:
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
- Reduce number of agents per group.
- Use lighter models (sonnet instead of opus).
- Check sandbox startup time.
**Fix:** Add to broadcast config or remove from bindings.
</Accordion>
</AccordionGroup>
### Performance Issues
**If slow with many agents:**
- Reduce number of agents per group
- Use lighter models (sonnet instead of opus)
- Check sandbox startup time
## Examples
<AccordionGroup>
<Accordion title="Example 1: Code review team">
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": [
"code-formatter",
"security-scanner",
"test-coverage",
"docs-checker"
]
### Example 1: Code Review Team
```json
{
"broadcast": {
"strategy": "parallel",
"120363403215116621@g.us": [
"code-formatter",
"security-scanner",
"test-coverage",
"docs-checker"
]
},
"agents": {
"list": [
{
"id": "code-formatter",
"workspace": "~/agents/formatter",
"tools": { "allow": ["read", "write"] }
},
"agents": {
"list": [
{
"id": "code-formatter",
"workspace": "~/agents/formatter",
"tools": { "allow": ["read", "write"] }
},
{
"id": "security-scanner",
"workspace": "~/agents/security",
"tools": { "allow": ["read", "exec"] }
},
{
"id": "test-coverage",
"workspace": "~/agents/testing",
"tools": { "allow": ["read", "exec"] }
},
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
]
}
}
```
**User sends:** Code snippet.
**Responses:**
- code-formatter: "Fixed indentation and added type hints"
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
- test-coverage: "Coverage is 45%, missing tests for error cases"
- docs-checker: "Missing docstring for function `process_data`"
</Accordion>
<Accordion title="Example 2: Multi-language support">
```json
{
"broadcast": {
"strategy": "sequential",
"+15555550123": ["detect-language", "translator-en", "translator-de"]
{
"id": "security-scanner",
"workspace": "~/agents/security",
"tools": { "allow": ["read", "exec"] }
},
"agents": {
"list": [
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
]
}
}
```
</Accordion>
</AccordionGroup>
{
"id": "test-coverage",
"workspace": "~/agents/testing",
"tools": { "allow": ["read", "exec"] }
},
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
]
}
}
```
## API reference
**User sends:** Code snippet
**Responses:**
### Config schema
- code-formatter: "Fixed indentation and added type hints"
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
- test-coverage: "Coverage is 45%, missing tests for error cases"
- docs-checker: "Missing docstring for function `process_data`"
### Example 2: Multi-Language Support
```json
{
"broadcast": {
"strategy": "sequential",
"+15555550123": ["detect-language", "translator-en", "translator-de"]
},
"agents": {
"list": [
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
]
}
}
```
## API Reference
### Config Schema
```typescript
interface OpenClawConfig {
@@ -437,21 +411,20 @@ interface OpenClawConfig {
### Fields
<ParamField path="strategy" type='"parallel" | "sequential"' default='"parallel"'>
How to process agents. `parallel` runs all agents simultaneously; `sequential` runs them in array order.
</ParamField>
<ParamField path="[peerId]" type="string[]">
WhatsApp group JID, E.164 number, or other peer ID. Value is the array of agent IDs that should process messages.
</ParamField>
- `strategy` (optional): How to process agents
- `"parallel"` (default): All agents process simultaneously
- `"sequential"`: Agents process in array order
- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID
- Value: Array of agent IDs that should process messages
## Limitations
1. **Max agents:** No hard limit, but 10+ agents may be slow.
2. **Shared context:** Agents don't see each other's responses (by design).
3. **Message ordering:** Parallel responses may arrive in any order.
4. **Rate limits:** All agents count toward WhatsApp rate limits.
1. **Max agents:** No hard limit, but 10+ agents may be slow
2. **Shared context:** Agents don't see each other's responses (by design)
3. **Message ordering:** Parallel responses may arrive in any order
4. **Rate limits:** All agents count toward WhatsApp rate limits
## Future enhancements
## Future Enhancements
Planned features:
@@ -462,8 +435,8 @@ Planned features:
## Related
- [Channel routing](/channels/channel-routing)
- [Groups](/channels/groups)
- [Multi-agent sandbox tools](/tools/multi-agent-sandbox-tools)
- [Channel routing](/channels/channel-routing)
- [Pairing](/channels/pairing)
- [Multi-agent sandbox tools](/tools/multi-agent-sandbox-tools)
- [Session management](/concepts/session)

View File

@@ -263,10 +263,6 @@ Now create some channels on your Discord server and start chatting. Your agent c
- Gateway owns the Discord connection.
- Reply routing is deterministic: Discord inbound replies back to Discord.
- Discord guild/channel metadata is added to the model prompt as untrusted
context, not as a user-visible reply prefix. If a model copies that envelope
back, OpenClaw strips the copied metadata from outbound replies and from
future replay context.
- By default (`session.dmScope=main`), direct chats share the agent main session (`agent:main:main`).
- Guild channels are isolated session keys (`agent:<agentId>:discord:channel:<channelId>`).
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).

View File

@@ -16,9 +16,7 @@ Feishu/Lark is an all-in-one collaboration platform where teams chat, share docu
## Quick start
<Note>
Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade with `openclaw update`.
</Note>
> **Requires OpenClaw 2026.4.25 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`.
<Steps>
<Step title="Run the channel setup wizard">
@@ -171,9 +169,7 @@ openclaw pairing list feishu
| `/reset` | Reset the current session |
| `/model` | Show or switch the AI model |
<Note>
Feishu/Lark does not support native slash-command menus, so send these as plain text messages.
</Note>
> Feishu/Lark does not support native slash-command menus, so send these as plain text messages.
---
@@ -217,11 +213,6 @@ Feishu/Lark does not support native slash-command menus, so send these as plain
appId: "cli_xxx",
appSecret: "xxx",
name: "Primary bot",
tts: {
providers: {
openai: { voice: "shimmer" },
},
},
},
backup: {
appId: "cli_yyy",
@@ -236,10 +227,6 @@ Feishu/Lark does not support native slash-command menus, so send these as plain
```
`defaultAccount` controls which account is used when outbound APIs do not specify an `accountId`.
`accounts.<id>.tts` uses the same shape as `messages.tts` and deep-merges over
global TTS config, so multi-bot Feishu setups can keep shared provider
credentials globally while overriding only voice, model, persona, or auto mode
per account.
### Message limits
@@ -399,7 +386,6 @@ Full configuration: [Gateway configuration](/gateway/configuration)
| `channels.feishu.accounts.<id>.appId` | App ID | — |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | — |
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
@@ -428,15 +414,6 @@ Full configuration: [Gateway configuration](/gateway/configuration)
- ✅ Video/media
- ✅ Stickers
Inbound Feishu/Lark audio messages are normalized as media placeholders instead
of raw `file_key` JSON. When `tools.media.audio` is configured, OpenClaw
downloads the voice-note resource and runs shared audio transcription before the
agent turn, so the agent receives the spoken transcript. If Feishu includes
transcript text directly in the audio payload, that text is used without another
ASR call. Without an audio transcription provider, the agent still receives a
`<media:audio>` placeholder plus the saved attachment, not the raw Feishu
resource payload.
### Send
- ✅ Text

View File

@@ -7,9 +7,7 @@ title: "Group messages"
Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
<Note>
`agents.list[].groupChat.mentionPatterns` is also used by Telegram, Discord, Slack, and iMessage. This doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent, or use `messages.groupChat.mentionPatterns` as a global fallback.
</Note>
Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback).
## Current implementation (2025-12-03)

View File

@@ -3,14 +3,14 @@ summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft
read_when:
- Changing group chat behavior or mention gating
title: "Groups"
sidebarTitle: "Groups"
---
OpenClaw treats group chats consistently across surfaces: Discord, iMessage, Matrix, Microsoft Teams, Signal, Slack, Telegram, WhatsApp, Zalo.
## Beginner intro (2 minutes)
OpenClaw "lives" on your own messaging accounts. There is no separate WhatsApp bot user. If **you** are in a group, OpenClaw can see that group and respond there.
OpenClaw lives on your own messaging accounts. There is no separate WhatsApp bot user.
If **you** are in a group, OpenClaw can see that group and respond there.
Default behavior:
@@ -19,13 +19,11 @@ Default behavior:
Translation: allowlisted senders can trigger OpenClaw by mentioning it.
<Note>
**TL;DR**
- **DM access** is controlled by `*.allowFrom`.
- **Group access** is controlled by `*.groupPolicy` + allowlists (`*.groups`, `*.groupAllowFrom`).
- **Reply triggering** is controlled by mention gating (`requireMention`, `/activation`).
</Note>
> TL;DR
>
> - **DM access** is controlled by `*.allowFrom`.
> - **Group access** is controlled by `*.groupPolicy` + allowlists (`*.groups`, `*.groupAllowFrom`).
> - **Reply triggering** is controlled by mention gating (`requireMention`, `/activation`).
Quick flow (what happens to a group message):
@@ -45,20 +43,18 @@ Two different controls are involved in group safety:
By default, OpenClaw prioritizes normal chat behavior and keeps context mostly as received. This means allowlists primarily decide who can trigger actions, not a universal redaction boundary for every quoted or historical snippet.
<AccordionGroup>
<Accordion title="Current behavior is channel-specific">
- Some channels already apply sender-based filtering for supplemental context in specific paths (for example Slack thread seeding, Matrix reply/thread lookups).
- Other channels still pass quote/reply/forward context through as received.
</Accordion>
<Accordion title="Hardening direction (planned)">
- `contextVisibility: "all"` (default) keeps current as-received behavior.
- `contextVisibility: "allowlist"` filters supplemental context to allowlisted senders.
- `contextVisibility: "allowlist_quote"` is `allowlist` plus one explicit quote/reply exception.
Current behavior is channel-specific:
Until this hardening model is implemented consistently across channels, expect differences by surface.
- Some channels already apply sender-based filtering for supplemental context in specific paths (for example Slack thread seeding, Matrix reply/thread lookups).
- Other channels still pass quote/reply/forward context through as received.
</Accordion>
</AccordionGroup>
Hardening direction (planned):
- `contextVisibility: "all"` (default) keeps current as-received behavior.
- `contextVisibility: "allowlist"` filters supplemental context to allowlisted senders.
- `contextVisibility: "allowlist_quote"` is `allowlist` plus one explicit quote/reply exception.
Until this hardening model is implemented consistently across channels, expect differences by surface.
![Group message flow](/images/groups-flow.svg)
@@ -82,69 +78,63 @@ If you want...
## Pattern: personal DMs + public groups (single agent)
Yes — this works well if your "personal" traffic is **DMs** and your "public" traffic is **groups**.
Yes — this works well if your personal traffic is **DMs** and your public traffic is **groups**.
Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main:<channel>:group:<id>`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in the configured sandbox backend while your main DM session stays on-host. Docker is the default backend if you do not choose one.
This gives you one agent "brain" (shared workspace + memory), but two execution postures:
This gives you one agent brain (shared workspace + memory), but two execution postures:
- **DMs**: full tools (host)
- **Groups**: sandbox + restricted tools
<Note>
If you need truly separate workspaces/personas ("personal" and "public" must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent).
</Note>
> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent).
<Tabs>
<Tab title="DMs on host, groups sandboxed">
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main", // groups/channels are non-main -> sandboxed
scope: "session", // strongest isolation (one container per group/channel)
workspaceAccess: "none",
},
},
Example (DMs on host, groups sandboxed + messaging-only tools):
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main", // groups/channels are non-main -> sandboxed
scope: "session", // strongest isolation (one container per group/channel)
workspaceAccess: "none",
},
},
},
tools: {
sandbox: {
tools: {
sandbox: {
tools: {
// If allow is non-empty, everything else is blocked (deny still wins).
allow: ["group:messaging", "group:sessions"],
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"],
},
// If allow is non-empty, everything else is blocked (deny still wins).
allow: ["group:messaging", "group:sessions"],
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"],
},
},
},
}
```
Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
workspaceAccess: "none",
docker: {
binds: [
// hostPath:containerPath:mode
"/home/user/FriendsShared:/data:ro",
],
},
},
}
```
</Tab>
<Tab title="Groups see only an allowlisted folder">
Want "groups can only see folder X" instead of "no host access"? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
workspaceAccess: "none",
docker: {
binds: [
// hostPath:containerPath:mode
"/home/user/FriendsShared:/data:ro",
],
},
},
},
},
}
```
</Tab>
</Tabs>
},
},
}
```
Related:
@@ -212,40 +202,33 @@ Control how group/room messages are handled per channel:
| `"disabled"` | Block all group messages entirely. |
| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. |
<AccordionGroup>
<Accordion title="Per-channel notes">
- `groupPolicy` is separate from mention-gating (which requires @mentions).
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
- Slack: allowlist uses `channels.slack.channels`.
- Matrix: allowlist uses `channels.matrix.groups`. Prefer room IDs or aliases; joined-room name lookup is best-effort, and unresolved names are ignored at runtime. Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
- Runtime safety: when a provider block is completely missing (`channels.<provider>` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`.
</Accordion>
</AccordionGroup>
Notes:
- `groupPolicy` is separate from mention-gating (which requires @mentions).
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
- Slack: allowlist uses `channels.slack.channels`.
- Matrix: allowlist uses `channels.matrix.groups`. Prefer room IDs or aliases; joined-room name lookup is best-effort, and unresolved names are ignored at runtime. Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
- Runtime safety: when a provider block is completely missing (`channels.<provider>` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`.
Quick mental model (evaluation order for group messages):
<Steps>
<Step title="groupPolicy">
`groupPolicy` (open/disabled/allowlist).
</Step>
<Step title="Group allowlists">
Group allowlists (`*.groups`, `*.groupAllowFrom`, channel-specific allowlist).
</Step>
<Step title="Mention gating">
Mention gating (`requireMention`, `/activation`).
</Step>
</Steps>
1. `groupPolicy` (open/disabled/allowlist)
2. group allowlists (`*.groups`, `*.groupAllowFrom`, channel-specific allowlist)
3. mention gating (`requireMention`, `/activation`)
## Mention gating (default)
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention when the channel supports reply metadata. Quoting a bot message can also count as an implicit mention on channels that expose quote metadata. Current built-in cases include Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
Replying to a bot message counts as an implicit mention when the channel
supports reply metadata. Quoting a bot message can also count as an implicit
mention on channels that expose quote metadata. Current built-in cases include
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
```json5
{
@@ -283,41 +266,31 @@ Replying to a bot message counts as an implicit mention when the channel support
}
```
<AccordionGroup>
<Accordion title="Mention gating notes">
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Groups where silent replies are allowed treat clean empty or reasoning-only model turns as silent, equivalent to `NO_REPLY`. Direct chats still treat empty replies as a failed agent turn.
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
</Accordion>
</AccordionGroup>
Notes:
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
## Group/channel tool restrictions (optional)
Some channel configs support restricting which tools are available **inside a specific group/room/channel**.
- `tools`: allow/deny tools for the whole group.
- `toolsBySender`: per-sender overrides within the group. Use explicit key prefixes: `id:<senderId>`, `e164:<phone>`, `username:<handle>`, `name:<displayName>`, and `"*"` wildcard. Legacy unprefixed keys are still accepted and matched as `id:` only.
- `toolsBySender`: per-sender overrides within the group.
Use explicit key prefixes:
`id:<senderId>`, `e164:<phone>`, `username:<handle>`, `name:<displayName>`, and `"*"` wildcard.
Legacy unprefixed keys are still accepted and matched as `id:` only.
Resolution order (most specific wins):
<Steps>
<Step title="Group toolsBySender">
Group/channel `toolsBySender` match.
</Step>
<Step title="Group tools">
Group/channel `tools`.
</Step>
<Step title="Default toolsBySender">
Default (`"*"`) `toolsBySender` match.
</Step>
<Step title="Default tools">
Default (`"*"`) `tools`.
</Step>
</Steps>
1. group/channel `toolsBySender` match
2. group/channel `tools`
3. default (`"*"`) `toolsBySender` match
4. default (`"*"`) `tools`
Example (Telegram):
@@ -339,67 +312,68 @@ Example (Telegram):
}
```
<Note>
Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins). Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, Microsoft Teams `teams.*.channels.*`).
</Note>
Notes:
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, Microsoft Teams `teams.*.channels.*`).
## Group allowlists
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
<Warning>
Common confusion: DM pairing approval is not the same as group authorization. For channels that support DM pairing, the pairing store unlocks DMs only. Group commands still require explicit group sender authorization from config allowlists such as `groupAllowFrom` or the documented config fallback for that channel.
</Warning>
Common confusion: DM pairing approval is not the same as group authorization.
For channels that support DM pairing, the pairing store unlocks DMs only. Group commands still require explicit group sender authorization from config allowlists such as `groupAllowFrom` or the documented config fallback for that channel.
Common intents (copy/paste):
<Tabs>
<Tab title="Disable all group replies">
```json5
{
channels: { whatsapp: { groupPolicy: "disabled" } },
}
```
</Tab>
<Tab title="Allow only specific groups (WhatsApp)">
```json5
{
channels: {
whatsapp: {
groups: {
"123@g.us": { requireMention: true },
"456@g.us": { requireMention: false },
},
},
1. Disable all group replies
```json5
{
channels: { whatsapp: { groupPolicy: "disabled" } },
}
```
2. Allow only specific groups (WhatsApp)
```json5
{
channels: {
whatsapp: {
groups: {
"123@g.us": { requireMention: true },
"456@g.us": { requireMention: false },
},
}
```
</Tab>
<Tab title="Allow all groups but require mention">
```json5
{
channels: {
whatsapp: {
groups: { "*": { requireMention: true } },
},
},
}
```
</Tab>
<Tab title="Owner-only triggers (WhatsApp)">
```json5
{
channels: {
whatsapp: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
groups: { "*": { requireMention: true } },
},
},
}
```
</Tab>
</Tabs>
},
},
}
```
3. Allow all groups but require mention (explicit)
```json5
{
channels: {
whatsapp: {
groups: { "*": { requireMention: true } },
},
},
}
```
4. Only the owner can trigger in groups (WhatsApp)
```json5
{
channels: {
whatsapp: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
groups: { "*": { requireMention: true } },
},
},
}
```
## Activation (owner-only)
@@ -408,7 +382,7 @@ Group owners can toggle per-group activation:
- `/activation mention`
- `/activation always`
Owner is determined by `channels.whatsapp.allowFrom` (or the bot's self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`.
Owner is determined by `channels.whatsapp.allowFrom` (or the bots self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`.
## Context fields
@@ -420,7 +394,7 @@ Group inbound payloads set:
- `WasMentioned` (mention gating result)
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
Channel-specific notes:
Channel specific notes:
- BlueBubbles can optionally enrich unnamed macOS group participants from the local Contacts database before populating `GroupMembers`. This is off by default and only runs after normal group gating passes.
@@ -442,7 +416,7 @@ See [Group messages](/channels/group-messages) for WhatsApp-only behavior (histo
## Related
- [Group messages](/channels/group-messages)
- [Broadcast groups](/channels/broadcast-groups)
- [Channel routing](/channels/channel-routing)
- [Group messages](/channels/group-messages)
- [Pairing](/channels/pairing)

View File

@@ -132,8 +132,6 @@ New user-defined `override` rules are inserted ahead of default suppress rules,
If you run Synapse behind a reverse proxy or workers, make sure `/_matrix/client/.../pushrules/` reaches Synapse correctly. Push delivery is handled by the main process or `synapse.app.pusher` / configured pusher workers — ensure those are healthy.
The rule uses the `event_property_is` push-rule condition (MSC3758, push rule v1.10), which was added to Synapse in 2023. Older Synapse releases accept the `PUT pushrules/...` call but silently never match the condition — upgrade Synapse if no notification arrives on a finalized preview edit.
</Accordion>
<Accordion title="Tuwunel">

File diff suppressed because it is too large Load Diff

View File

@@ -4,68 +4,62 @@ read_when:
- Setting up Mattermost
- Debugging Mattermost routing
title: "Mattermost"
sidebarTitle: "Mattermost"
---
Status: bundled plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. Mattermost is a self-hostable team messaging platform; see the official site at [mattermost.com](https://mattermost.com) for product details and downloads.
Status: bundled plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
Mattermost is a self-hostable team messaging platform; see the official site at
[mattermost.com](https://mattermost.com) for product details and downloads.
## Bundled plugin
<Note>
Mattermost ships as a bundled plugin in current OpenClaw releases, so normal packaged builds do not need a separate install.
</Note>
Mattermost ships as a bundled plugin in current OpenClaw releases, so normal
packaged builds do not need a separate install.
If you are on an older build or a custom install that excludes Mattermost, install it manually:
If you are on an older build or a custom install that excludes Mattermost,
install it manually:
<Tabs>
<Tab title="npm registry">
```bash
openclaw plugins install @openclaw/mattermost
```
</Tab>
<Tab title="Local checkout">
```bash
openclaw plugins install ./path/to/local/mattermost-plugin
```
</Tab>
</Tabs>
Install via CLI (npm registry):
```bash
openclaw plugins install @openclaw/mattermost
```
Local checkout (when running from a git repo):
```bash
openclaw plugins install ./path/to/local/mattermost-plugin
```
Details: [Plugins](/tools/plugin)
## Quick setup
<Steps>
<Step title="Ensure plugin is available">
Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above.
</Step>
<Step title="Create a Mattermost bot">
Create a Mattermost bot account and copy the **bot token**.
</Step>
<Step title="Copy the base URL">
Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
</Step>
<Step title="Configure OpenClaw and start the gateway">
Minimal config:
1. Ensure the Mattermost plugin is available.
- Current packaged OpenClaw releases already bundle it.
- Older/custom installs can add it manually with the commands above.
2. Create a Mattermost bot account and copy the **bot token**.
3. Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
4. Configure OpenClaw and start the gateway.
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing",
},
},
}
```
Minimal config:
</Step>
</Steps>
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing",
},
},
}
```
## Native slash commands
Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via the Mattermost API and receives callback POSTs on the gateway HTTP server.
Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via
the Mattermost API and receives callback POSTs on the gateway HTTP server.
```json5
{
@@ -83,33 +77,27 @@ Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash
}
```
<AccordionGroup>
<Accordion title="Behavior notes">
- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable.
- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`.
- For multi-account setups, `commands` can be set at the top level or under `channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
- Command callbacks are validated with the per-command tokens returned by Mattermost when OpenClaw registers `oc_*` commands.
- Slash callbacks fail closed when registration failed, startup was partial, or the callback token does not match one of the registered commands.
</Accordion>
<Accordion title="Reachability requirement">
The callback endpoint must be reachable from the Mattermost server.
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw.
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw.
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`.
</Accordion>
<Accordion title="Mattermost egress allowlist">
If your callback targets private/tailnet/internal addresses, set Mattermost `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
Use host/domain entries, not full URLs.
Notes:
- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable.
- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`.
- For multi-account setups, `commands` can be set at the top level or under
`channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
- Command callbacks are validated with the per-command tokens returned by
Mattermost when OpenClaw registers `oc_*` commands.
- Slash callbacks fail closed when registration failed, startup was partial, or
the callback token does not match one of the registered commands.
- Reachability requirement: the callback endpoint must be reachable from the Mattermost server.
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw.
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw.
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`.
- Mattermost egress allowlist requirement:
- If your callback targets private/tailnet/internal addresses, set Mattermost
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
- Use host/domain entries, not full URLs.
- Good: `gateway.tailnet-name.ts.net`
- Bad: `https://gateway.tailnet-name.ts.net`
</Accordion>
</AccordionGroup>
## Environment variables (default account)
Set these on the gateway host if you prefer env vars:
@@ -117,27 +105,17 @@ Set these on the gateway host if you prefer env vars:
- `MATTERMOST_BOT_TOKEN=...`
- `MATTERMOST_URL=https://chat.example.com`
<Note>
Env vars apply only to the **default** account (`default`). Other accounts must use config values.
`MATTERMOST_URL` cannot be set from a workspace `.env`; see [Workspace `.env` files](/gateway/security).
</Note>
## Chat modes
Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`:
<Tabs>
<Tab title="oncall (default)">
Respond only when @mentioned in channels.
</Tab>
<Tab title="onmessage">
Respond to every channel message.
</Tab>
<Tab title="onchar">
Respond when a message starts with a trigger prefix.
</Tab>
</Tabs>
- `oncall` (default): respond only when @mentioned in channels.
- `onmessage`: respond to every channel message.
- `onchar`: respond when a message starts with a trigger prefix.
Config example:
@@ -159,10 +137,12 @@ Notes:
## Threading and sessions
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the main channel or start a thread under the triggering post.
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
main channel or start a thread under the triggering post.
- `off` (default): only reply in a thread when the inbound post is already in one.
- `first`: for top-level channel/group posts, start a thread under that post and route the conversation to a thread-scoped session.
- `first`: for top-level channel/group posts, start a thread under that post and route the
conversation to a thread-scoped session.
- `all`: same behavior as `first` for Mattermost today.
- Direct messages ignore this setting and stay non-threaded.
@@ -181,7 +161,8 @@ Config example:
Notes:
- Thread-scoped sessions use the triggering post id as the thread root.
- `first` and `all` are currently equivalent because once Mattermost has a thread root, follow-up chunks and media continue in that same thread.
- `first` and `all` are currently equivalent because once Mattermost has a thread root,
follow-up chunks and media continue in that same thread.
## Access control (DMs)
@@ -195,7 +176,8 @@ Notes:
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended).
- Per-channel mention overrides live under `channels.mattermost.groups.<channelId>.requireMention` or `channels.mattermost.groups["*"].requireMention` for a default.
- Per-channel mention overrides live under `channels.mattermost.groups.<channelId>.requireMention`
or `channels.mattermost.groups["*"].requireMention` for a default.
- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`.
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
@@ -224,7 +206,6 @@ Use these target formats with `openclaw message send` or cron/webhooks:
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
<Warning>
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
OpenClaw resolves them **user-first**:
@@ -233,13 +214,14 @@ OpenClaw resolves them **user-first**:
- Otherwise the ID is treated as a **channel ID**.
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
</Warning>
## DM channel retry
When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it retries transient direct-channel creation failures by default.
When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it
retries transient direct-channel creation failures by default.
Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin, or `channels.mattermost.accounts.<id>.dmChannelRetry` for one account.
Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin,
or `channels.mattermost.accounts.<id>.dmChannelRetry` for one account.
```json5
{
@@ -278,19 +260,15 @@ Enable via `channels.mattermost.streaming`:
}
```
<AccordionGroup>
<Accordion title="Streaming modes">
- `partial` is the usual choice: one preview post that is edited as the reply grows, then finalized with the complete answer.
- `block` uses append-style draft chunks inside the preview post.
- `progress` shows a status preview while generating and only posts the final answer at completion.
- `off` disables preview streaming.
</Accordion>
<Accordion title="Streaming behavior notes">
- If the stream cannot be finalized in place (for example the post was deleted mid-stream), OpenClaw falls back to sending a fresh final post so the reply is never lost.
- Reasoning-only payloads are suppressed from channel posts, including text that arrives as a `> Reasoning:` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only.
- See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix.
</Accordion>
</AccordionGroup>
Notes:
- `partial` is the usual choice: one preview post that is edited as the reply grows, then finalized with the complete answer.
- `block` uses append-style draft chunks inside the preview post.
- `progress` shows a status preview while generating and only posts the final answer at completion.
- `off` disables preview streaming.
- If the stream cannot be finalized in place (for example the post was deleted mid-stream), OpenClaw falls back to sending a fresh final post so the reply is never lost.
- Reasoning-only payloads are suppressed from channel posts, including text that arrives as a `> Reasoning:` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only.
- See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix.
## Reactions (message tool)
@@ -314,7 +292,8 @@ Config:
## Interactive buttons (message tool)
Send messages with clickable buttons. When a user clicks a button, the agent receives the selection and can respond.
Send messages with clickable buttons. When a user clicks a button, the agent receives the
selection and can respond.
Enable buttons by adding `inlineButtons` to the channel capabilities:
@@ -336,46 +315,44 @@ message action=send channel=mattermost target=channel:<channelId> buttons=[[{"te
Button fields:
<ParamField path="text" type="string" required>
Display label.
</ParamField>
<ParamField path="callback_data" type="string" required>
Value sent back on click (used as the action ID).
</ParamField>
<ParamField path="style" type='"default" | "primary" | "danger"'>
Button style.
</ParamField>
- `text` (required): display label.
- `callback_data` (required): value sent back on click (used as the action ID).
- `style` (optional): `"default"`, `"primary"`, or `"danger"`.
When a user clicks a button:
<Steps>
<Step title="Buttons replaced with confirmation">
All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user").
</Step>
<Step title="Agent receives the selection">
The agent receives the selection as an inbound message and responds.
</Step>
</Steps>
1. All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user").
2. The agent receives the selection as an inbound message and responds.
<AccordionGroup>
<Accordion title="Implementation notes">
- Button callbacks use HMAC-SHA256 verification (automatic, no config needed).
- Mattermost strips callback data from its API responses (security feature), so all buttons are removed on click — partial removal is not possible.
- Action IDs containing hyphens or underscores are sanitized automatically (Mattermost routing limitation).
</Accordion>
<Accordion title="Config and reachability">
- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to enable the buttons tool description in the agent system prompt.
- `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot reach the gateway at its bind host directly.
- In multi-account setups, you can also set the same field under `channels.mattermost.accounts.<id>.interactions.callbackBaseUrl`.
- If `interactions.callbackBaseUrl` is omitted, OpenClaw derives the callback URL from `gateway.customBindHost` + `gateway.port`, then falls back to `http://localhost:<port>`.
- Reachability rule: the button callback URL must be reachable from the Mattermost server. `localhost` only works when Mattermost and OpenClaw run on the same host/network namespace.
- If your callback target is private/tailnet/internal, add its host/domain to Mattermost `ServiceSettings.AllowedUntrustedInternalConnections`.
</Accordion>
</AccordionGroup>
Notes:
- Button callbacks use HMAC-SHA256 verification (automatic, no config needed).
- Mattermost strips callback data from its API responses (security feature), so all buttons
are removed on click — partial removal is not possible.
- Action IDs containing hyphens or underscores are sanitized automatically
(Mattermost routing limitation).
Config:
- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to
enable the buttons tool description in the agent system prompt.
- `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button
callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot
reach the gateway at its bind host directly.
- In multi-account setups, you can also set the same field under
`channels.mattermost.accounts.<id>.interactions.callbackBaseUrl`.
- If `interactions.callbackBaseUrl` is omitted, OpenClaw derives the callback URL from
`gateway.customBindHost` + `gateway.port`, then falls back to `http://localhost:<port>`.
- Reachability rule: the button callback URL must be reachable from the Mattermost server.
`localhost` only works when Mattermost and OpenClaw run on the same host/network namespace.
- If your callback target is private/tailnet/internal, add its host/domain to Mattermost
`ServiceSettings.AllowedUntrustedInternalConnections`.
### Direct API integration (external scripts)
External scripts and webhooks can post buttons directly via the Mattermost REST API instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from the plugin when possible; if posting raw JSON, follow these rules:
External scripts and webhooks can post buttons directly via the Mattermost REST API
instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from
the plugin when possible; if posting raw JSON, follow these rules:
**Payload structure:**
@@ -409,38 +386,29 @@ External scripts and webhooks can post buttons directly via the Mattermost REST
}
```
<Warning>
**Critical rules**
**Critical rules:**
1. Attachments go in `props.attachments`, not top-level `attachments` (silently ignored).
2. Every action needs `type: "button"` — without it, clicks are swallowed silently.
3. Every action needs an `id` field — Mattermost ignores actions without IDs.
4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break Mattermost's server-side action routing (returns 404). Strip them before use.
5. `context.action_id` must match the button's `id` so the confirmation message shows the button name (e.g., "Approve") instead of a raw ID.
4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break
Mattermost's server-side action routing (returns 404). Strip them before use.
5. `context.action_id` must match the button's `id` so the confirmation message shows the
button name (e.g., "Approve") instead of a raw ID.
6. `context.action_id` is required — the interaction handler returns 400 without it.
</Warning>
**HMAC token generation**
**HMAC token generation:**
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens that match the gateway's verification logic:
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens
that match the gateway's verification logic:
<Steps>
<Step title="Derive the secret from the bot token">
`HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)`
</Step>
<Step title="Build the context object">
Build the context object with all fields **except** `_token`.
</Step>
<Step title="Serialize with sorted keys">
Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify` with sorted keys, which produces compact output).
</Step>
<Step title="Sign the payload">
`HMAC-SHA256(key=secret, data=serializedContext)`
</Step>
<Step title="Add the token">
Add the resulting hex digest as `_token` in the context.
</Step>
</Steps>
1. Derive the secret from the bot token:
`HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)`
2. Build the context object with all fields **except** `_token`.
3. Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify`
with sorted keys, which produces compact output).
4. Sign: `HMAC-SHA256(key=secret, data=serializedContext)`
5. Add the resulting hex digest as `_token` in the context.
Python example:
@@ -459,18 +427,22 @@ token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
context = {**ctx, "_token": token}
```
<AccordionGroup>
<Accordion title="Common HMAC pitfalls">
- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use `separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`).
- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then signs everything remaining. Signing a subset causes silent verification failure.
- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may reorder context fields when storing the payload.
- Derive the secret from the bot token (deterministic), not random bytes. The secret must be the same across the process that creates buttons and the gateway that verifies.
</Accordion>
</AccordionGroup>
Common HMAC pitfalls:
- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use
`separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`).
- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then
signs everything remaining. Signing a subset causes silent verification failure.
- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may
reorder context fields when storing the payload.
- Derive the secret from the bot token (deterministic), not random bytes. The secret
must be the same across the process that creates buttons and the gateway that verifies.
## Directory adapter
The Mattermost plugin includes a directory adapter that resolves channel and user names via the Mattermost API. This enables `#channel-name` and `@username` targets in `openclaw message send` and cron/webhook deliveries.
The Mattermost plugin includes a directory adapter that resolves channel and user names
via the Mattermost API. This enables `#channel-name` and `@username` targets in
`openclaw message send` and cron/webhook deliveries.
No configuration is needed — the adapter uses the bot token from the account config.
@@ -493,38 +465,34 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`:
## Troubleshooting
<AccordionGroup>
<Accordion title="No replies in channels">
Ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
</Accordion>
<Accordion title="Auth or multi-account errors">
- Check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.
</Accordion>
<Accordion title="Native slash commands fail">
- `Unauthorized: invalid command token.`: OpenClaw did not accept the callback token. Typical causes:
- slash command registration failed or only partially completed at startup
- the callback is hitting the wrong gateway/account
- Mattermost still has old commands pointing at a previous callback target
- the gateway restarted without reactivating slash commands
- If native slash commands stop working, check logs for `mattermost: failed to register slash commands` or `mattermost: native slash commands enabled but no commands could be registered`.
- If `callbackUrl` is omitted and logs warn that the callback resolved to `http://127.0.0.1:18789/...`, that URL is probably only reachable when Mattermost runs on the same host/network namespace as OpenClaw. Set an explicit externally reachable `commands.callbackUrl` instead.
</Accordion>
<Accordion title="Buttons issues">
- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields.
- Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings.
- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only.
- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above.
- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload.
- Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value.
- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config.
</Accordion>
</AccordionGroup>
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
- Auth errors: check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.
- Native slash commands return `Unauthorized: invalid command token.`: OpenClaw
did not accept the callback token. Typical causes:
- slash command registration failed or only partially completed at startup
- the callback is hitting the wrong gateway/account
- Mattermost still has old commands pointing at a previous callback target
- the gateway restarted without reactivating slash commands
- If native slash commands stop working, check logs for
`mattermost: failed to register slash commands` or
`mattermost: native slash commands enabled but no commands could be registered`.
- If `callbackUrl` is omitted and logs warn that the callback resolved to
`http://127.0.0.1:18789/...`, that URL is probably only reachable when
Mattermost runs on the same host/network namespace as OpenClaw. Set an
explicit externally reachable `commands.callbackUrl` instead.
- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields.
- Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings.
- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only.
- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above.
- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload.
- Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value.
- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config.
## Related
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Channels Overview](/channels) — all supported channels
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Pairing](/channels/pairing) — DM authentication and pairing flow
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Security](/gateway/security) — access model and hardening

View File

@@ -39,9 +39,7 @@ teams login
teams status # verify you're logged in and see your tenant info
```
<Note>
The Teams CLI is currently in preview. Commands and flags may change between releases.
</Note>
> **Note:** The Teams CLI is currently in preview. Commands and flags may change between releases.
**2. Start a tunnel** (Teams can't reach localhost)
@@ -57,9 +55,7 @@ devtunnel host my-openclaw-bot
# Your endpoint: https://<tunnel-id>.devtunnels.ms/api/messages
```
<Note>
`--allow-anonymous` is required because Teams cannot authenticate with devtunnels. Each incoming bot request is still validated by the Teams SDK automatically.
</Note>
> **Note:** `--allow-anonymous` is required because Teams can't authenticate with devtunnels. Each incoming bot request is still validated by the Teams SDK automatically.
Alternatives: `ngrok http 3978` or `tailscale funnel 3978` (but these may change URLs each session).
@@ -116,9 +112,7 @@ This runs diagnostics across bot registration, AAD app config, manifest validity
For production deployments, consider using [federated authentication](#federated-authentication-certificate--managed-identity) (certificate or managed identity) instead of client secrets.
<Note>
Group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom`, or use `groupPolicy: "open"` to allow any member (mention-gated).
</Note>
Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated).
## Goals
@@ -223,9 +217,7 @@ If you can't use the Teams CLI, you can set up the bot manually through the Azur
| **Type of App** | **Single Tenant** (recommended - see note below) |
| **Creation type** | **Create new Microsoft App ID** |
<Warning>
Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single Tenant** for new bots.
</Warning>
> **Deprecation notice:** Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single Tenant** for new bots.
3. Click **Review + create****Create** (wait ~1-2 minutes)
@@ -922,9 +914,7 @@ openclaw message send --channel msteams --target "conversation:19:abc...@thread.
}
```
<Note>
Without the `user:` prefix, names default to group or team resolution. Always use `user:` when targeting people by display name.
</Note>
Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name.
## Proactive messaging

View File

@@ -52,9 +52,8 @@ Account scoping behavior:
Treat these as sensitive (they gate access to your assistant).
<Note>
This store is for DM access. Group authorization is separate. Approving a DM pairing code does not automatically allow that sender to run group commands or control the bot in groups. For group access, configure the channel's explicit group allowlists (for example `groupAllowFrom`, `groups`, or per-group or per-topic overrides depending on the channel).
</Note>
Important: this store is for DM access. Group authorization is separate.
Approving a DM pairing code does not automatically allow that sender to run group commands or control the bot in groups. For group access, configure the channel's explicit group allowlists (for example `groupAllowFrom`, `groups`, or per-group/per-topic overrides depending on the channel).
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
@@ -84,8 +83,6 @@ That bootstrap token carries the built-in pairing bootstrap profile:
- bootstrap scope checks are role-prefixed, not one flat scope pool:
operator scope entries only satisfy operator requests, and non-operator roles
must still request scopes under their own role prefix
- later token rotation/revocation remains bounded by both the device's approved
role contract and the caller session's operator scopes
Treat the setup code like a password while it is valid.
@@ -101,9 +98,11 @@ If the same device retries with different auth details (for example different
role/scopes/public key), the previous pending request is superseded and a new
`requestId` is created.
<Note>
An already paired device does not get broader access silently. If it reconnects asking for more scopes or a broader role, OpenClaw keeps the existing approval as-is and creates a fresh pending upgrade request. Use `openclaw devices list` to compare the currently approved access with the newly requested access before you approve.
</Note>
Important: an already paired device does not get broader access silently. If it
reconnects asking for more scopes or a broader role, OpenClaw keeps the
existing approval as-is and creates a fresh pending upgrade request. Use
`openclaw devices list` to compare the currently approved access with the newly
requested access before you approve.
### Optional trusted-CIDR node auto-approve

View File

@@ -122,10 +122,10 @@ openclaw channels add --channel qqbot --account bot2 --token "222222222:secret-o
STT and TTS support two-level configuration with priority fallback:
| Setting | Plugin-specific | Framework fallback |
| ------- | -------------------------------------------------------- | ----------------------------- |
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
| TTS | `channels.qqbot.tts`, `channels.qqbot.accounts.<id>.tts` | `messages.tts` |
| Setting | Plugin-specific | Framework fallback |
| ------- | -------------------- | ----------------------------- |
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
| TTS | `channels.qqbot.tts` | `messages.tts` |
```json5
{
@@ -140,23 +140,12 @@ STT and TTS support two-level configuration with priority fallback:
model: "your-tts-model",
voice: "your-voice",
},
accounts: {
qq-main: {
tts: {
providers: {
openai: { voice: "shimmer" },
},
},
},
},
},
},
}
```
Set `enabled: false` on either to disable.
Account-level TTS overrides use the same shape as `messages.tts` and deep-merge
over the channel/global TTS config.
Inbound QQ voice attachments are exposed to agents as audio media metadata while
keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]` plain
@@ -220,10 +209,6 @@ Approval prompts generated by the bot itself (for example, "allow this action?"
- **Bot replies "gone to Mars":** credentials not configured or Gateway not started.
- **No inbound messages:** verify `appId` and `clientSecret` are correct, and the
bot is enabled on the QQ Open Platform.
- **Repeated self-replies:** OpenClaw records QQ outbound ref indexes as
bot-authored and ignores inbound events whose current `msgIdx` matches that
same bot account. This prevents platform echo loops while still allowing users
to quote or reply to previous bot messages.
- **Setup with `--token-file` still shows unconfigured:** `--token-file` only sets
the AppSecret. You still need `appId` in config or `QQBOT_APP_ID`.
- **Proactive messages not arriving:** QQ may intercept bot-initiated messages if

View File

@@ -152,9 +152,7 @@ openclaw channels status --probe
- Approve code on the server: `openclaw pairing approve signal <PAIRING_CODE>`.
- Save the bot number as a contact on your phone to avoid "Unknown contact".
<Warning>
Registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup.
</Warning>
Important: registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup.
Upstream references:

View File

@@ -530,9 +530,7 @@ Manual reply tags are supported:
- `[[reply_to_current]]`
- `[[reply_to:<id>]]`
<Note>
`replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. Slack threads hide messages from the channel while Telegram replies stay visible inline.
</Note>
Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode — Slack threads hide messages from the channel while Telegram replies stay visible inline.
## Ack reactions

View File

@@ -298,8 +298,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
For text-only replies:
- short DM/group/topic previews: OpenClaw keeps the same preview message and performs a final edit in place
- previews older than about one minute: OpenClaw sends the completed reply as a fresh final message and then cleans up the preview, so Telegram's visible timestamp reflects completion time instead of the preview creation time
- DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
@@ -489,8 +489,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `first`
- `all`
When reply threading is enabled and the original Telegram text or caption is available, OpenClaw includes a native Telegram quote excerpt automatically. Telegram caps native quote text at 1024 UTF-16 code units, so longer messages are quoted from the start and fall back to a plain reply if Telegram rejects the quote.
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
</Accordion>

View File

@@ -3,69 +3,50 @@ summary: "Twitch chat bot configuration and setup"
read_when:
- Setting up Twitch chat integration for OpenClaw
title: "Twitch"
sidebarTitle: "Twitch"
---
Twitch chat support via IRC connection. OpenClaw connects as a Twitch user (bot account) to receive and send messages in channels.
## Bundled plugin
<Note>
Twitch ships as a bundled plugin in current OpenClaw releases, so normal packaged builds do not need a separate install.
</Note>
Twitch ships as a bundled plugin in current OpenClaw releases, so normal
packaged builds do not need a separate install.
If you are on an older build or a custom install that excludes Twitch, install it manually:
If you are on an older build or a custom install that excludes Twitch, install
it manually:
<Tabs>
<Tab title="npm registry">
```bash
openclaw plugins install @openclaw/twitch
```
</Tab>
<Tab title="Local checkout">
```bash
openclaw plugins install ./path/to/local/twitch-plugin
```
</Tab>
</Tabs>
Install via CLI (npm registry):
```bash
openclaw plugins install @openclaw/twitch
```
Local checkout (when running from a git repo):
```bash
openclaw plugins install ./path/to/local/twitch-plugin
```
Details: [Plugins](/tools/plugin)
## Quick setup (beginner)
<Steps>
<Step title="Ensure plugin is available">
Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above.
</Step>
<Step title="Create a Twitch bot account">
Create a dedicated Twitch account for the bot (or use an existing account).
</Step>
<Step title="Generate credentials">
Use [Twitch Token Generator](https://twitchtokengenerator.com/):
1. Ensure the Twitch plugin is available.
- Current packaged OpenClaw releases already bundle it.
- Older/custom installs can add it manually with the commands above.
2. Create a dedicated Twitch account for the bot (or use an existing account).
3. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token**
4. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/)
5. Configure the token:
- Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only)
- Or config: `channels.twitch.accessToken`
- If both are set, config takes precedence (env fallback is default-account only).
6. Start the gateway.
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token**
</Step>
<Step title="Find your Twitch user ID">
Use [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) to convert a username to a Twitch user ID.
</Step>
<Step title="Configure the token">
- Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only)
- Or config: `channels.twitch.accessToken`
If both are set, config takes precedence (env fallback is default-account only).
</Step>
<Step title="Start the gateway">
Start the gateway with the configured channel.
</Step>
</Steps>
<Warning>
Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
</Warning>
**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
Minimal config:
@@ -101,34 +82,31 @@ Use [Twitch Token Generator](https://twitchtokengenerator.com/):
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token**
<Note>
No manual app registration needed. Tokens expire after several hours.
</Note>
### Configure the bot
<Tabs>
<Tab title="Env var (default account only)">
```bash
OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123...
```
</Tab>
<Tab title="Config">
```json5
{
channels: {
twitch: {
enabled: true,
username: "openclaw",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk",
},
},
}
```
</Tab>
</Tabs>
**Env var (default account only):**
```bash
OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123...
```
**Or config:**
```json5
{
channels: {
twitch: {
enabled: true,
username: "openclaw",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk",
},
},
}
```
If both env and config are set, config takes precedence.
@@ -148,11 +126,9 @@ Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want
**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`.
<Note>
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) (Convert your Twitch username to ID)
</Note>
## Token refresh (optional)
@@ -175,7 +151,7 @@ The bot automatically refreshes tokens before expiration and logs refresh events
## Multi-account support
Use `channels.twitch.accounts` with per-account tokens. See [Configuration](/gateway/configuration) for the shared pattern.
Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern.
Example (one bot account in two channels):
@@ -202,65 +178,78 @@ Example (one bot account in two channels):
}
```
<Note>
Each account needs its own token (one token per channel).
</Note>
**Note:** Each account needs its own token (one token per channel).
## Access control
<Tabs>
<Tab title="User ID allowlist (most secure)">
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowFrom: ["123456789", "987654321"],
},
},
### Role-based restrictions
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowedRoles: ["moderator", "vip"],
},
},
}
```
</Tab>
<Tab title="Role-based">
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowedRoles: ["moderator", "vip"],
},
},
},
},
}
```
### Allowlist by User ID (most secure)
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowFrom: ["123456789", "987654321"],
},
},
}
```
},
},
}
```
`allowFrom` is a hard allowlist. When set, only those user IDs are allowed. If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead.
### Role-based access (alternative)
</Tab>
<Tab title="Disable @mention requirement">
By default, `requireMention` is `true`. To disable and respond to all messages:
`allowFrom` is a hard allowlist. When set, only those user IDs are allowed.
If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead:
```json5
{
channels: {
twitch: {
accounts: {
default: {
requireMention: false,
},
},
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowedRoles: ["moderator"],
},
},
}
```
},
},
}
```
</Tab>
</Tabs>
### Disable @mention requirement
By default, `requireMention` is `true`. To disable and respond to all messages:
```json5
{
channels: {
twitch: {
accounts: {
default: {
requireMention: false,
},
},
},
},
}
```
## Troubleshooting
@@ -271,77 +260,53 @@ openclaw doctor
openclaw channels status --probe
```
<AccordionGroup>
<Accordion title="Bot does not respond to messages">
- **Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove `allowFrom` and set `allowedRoles: ["all"]` to test.
- **Check the bot is in the channel:** The bot must join the channel specified in `channel`.
</Accordion>
<Accordion title="Token issues">
"Failed to connect" or authentication errors:
### Bot does not respond to messages
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
- Check token has `chat:read` and `chat:write` scopes
- If using token refresh, verify `clientSecret` and `refreshToken` are set
**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove
`allowFrom` and set `allowedRoles: ["all"]` to test.
</Accordion>
<Accordion title="Token refresh not working">
Check logs for refresh events:
**Check the bot is in the channel:** The bot must join the channel specified in `channel`.
```
Using env token source for mybot
Access token refreshed for user 123456 (expires in 14400s)
```
### Token issues
If you see "token refresh disabled (no refresh token)":
**"Failed to connect" or authentication errors:**
- Ensure `clientSecret` is provided
- Ensure `refreshToken` is provided
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
- Check token has `chat:read` and `chat:write` scopes
- If using token refresh, verify `clientSecret` and `refreshToken` are set
</Accordion>
</AccordionGroup>
### Token refresh not working
**Check logs for refresh events:**
```
Using env token source for mybot
Access token refreshed for user 123456 (expires in 14400s)
```
If you see "token refresh disabled (no refresh token)":
- Ensure `clientSecret` is provided
- Ensure `refreshToken` is provided
## Config
### Account config
**Account config:**
<ParamField path="username" type="string">
Bot username.
</ParamField>
<ParamField path="accessToken" type="string">
OAuth access token with `chat:read` and `chat:write`.
</ParamField>
<ParamField path="clientId" type="string">
Twitch Client ID (from Token Generator or your app).
</ParamField>
<ParamField path="channel" type="string" required>
Channel to join.
</ParamField>
<ParamField path="enabled" type="boolean" default="true">
Enable this account.
</ParamField>
<ParamField path="clientSecret" type="string">
Optional: for automatic token refresh.
</ParamField>
<ParamField path="refreshToken" type="string">
Optional: for automatic token refresh.
</ParamField>
<ParamField path="expiresIn" type="number">
Token expiry in seconds.
</ParamField>
<ParamField path="obtainmentTimestamp" type="number">
Token obtained timestamp.
</ParamField>
<ParamField path="allowFrom" type="string[]">
User ID allowlist.
</ParamField>
<ParamField path="allowedRoles" type='Array<"moderator" | "owner" | "vip" | "subscriber" | "all">'>
Role-based access control.
</ParamField>
<ParamField path="requireMention" type="boolean" default="true">
Require @mention.
</ParamField>
- `username` - Bot username
- `accessToken` - OAuth access token with `chat:read` and `chat:write`
- `clientId` - Twitch Client ID (from Token Generator or your app)
- `channel` - Channel to join (required)
- `enabled` - Enable this account (default: `true`)
- `clientSecret` - Optional: For automatic token refresh
- `refreshToken` - Optional: For automatic token refresh
- `expiresIn` - Token expiry in seconds
- `obtainmentTimestamp` - Token obtained timestamp
- `allowFrom` - User ID allowlist
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
- `requireMention` - Require @mention (default: `true`)
### Provider options
**Provider options:**
- `channels.twitch.enabled` - Enable/disable channel startup
- `channels.twitch.username` - Bot username (simplified single-account config)
@@ -403,25 +368,25 @@ Example:
}
```
## Safety and ops
## Safety & ops
- **Treat tokens like passwords** Never commit tokens to git.
- **Use automatic token refresh** for long-running bots.
- **Use user ID allowlists** instead of usernames for access control.
- **Monitor logs** for token refresh events and connection status.
- **Scope tokens minimally** Only request `chat:read` and `chat:write`.
- **If stuck**: Restart the gateway after confirming no other process owns the session.
- **Treat tokens like passwords** - Never commit tokens to git
- **Use automatic token refresh** for long-running bots
- **Use user ID allowlists** instead of usernames for access control
- **Monitor logs** for token refresh events and connection status
- **Scope tokens minimally** - Only request `chat:read` and `chat:write`
- **If stuck**: Restart the gateway after confirming no other process owns the session
## Limits
- **500 characters** per message (auto-chunked at word boundaries).
- Markdown is stripped before chunking.
- No rate limiting (uses Twitch's built-in rate limits).
- **500 characters** per message (auto-chunked at word boundaries)
- Markdown is stripped before chunking
- No rate limiting (uses Twitch's built-in rate limits)
## Related
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Channels Overview](/channels) — all supported channels
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Pairing](/channels/pairing) — DM authentication and pairing flow
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Channel Routing](/channels/channel-routing) — session routing for messages
- [Security](/gateway/security) — access model and hardening

View File

@@ -146,13 +146,11 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
## Runtime model
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
- Outbound sends require an active WhatsApp listener for the target account.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
- WhatsApp Web transport honors standard proxy environment variables on the gateway host (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` / lowercase variants). Prefer host-level proxy config over channel-specific WhatsApp proxy settings.
- When `messages.removeAckAfterReply` is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered.
## Plugin hooks and privacy
@@ -245,7 +243,6 @@ content and identifiers.
- explicit WhatsApp mentions of the bot identity
- configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- inbound voice-note transcripts for authorized group messages
- implicit reply-to-bot detection (reply sender matches bot identity)
Security note:
@@ -298,11 +295,6 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
- `<media:document>`
- `<media:sticker>`
Authorized group voice notes are transcribed before mention gating when the
body is only `<media:audio>`, so saying the bot mention in the voice note can
trigger the reply. If the transcript still does not mention the bot, the
transcript is kept in pending group history instead of the raw placeholder.
Location bodies use terse coordinate text. Location labels/comments and contact/vCard details are rendered as fenced untrusted metadata, not inline prompt text.
</Accordion>
@@ -373,7 +365,6 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
- reply payloads preserve `audioAsVoice`; TTS voice-note output for WhatsApp stays on this PTT path even when the provider returns MP3 or WebM
- native Ogg/Opus audio is sent as `audio/ogg; codecs=opus` for voice-note compatibility
- non-Ogg audio, including Microsoft Edge TTS MP3/WebM output, is transcoded with `ffmpeg` to 48 kHz mono Ogg/Opus before PTT delivery
- `/tts latest` sends the latest assistant reply as one voice note and suppresses repeat sends for the same reply; `/tts chat on|off|default` controls auto-TTS for the current WhatsApp chat
- animated GIF playback is supported via `gifPlayback: true` on video sends
- captions are applied to the first media item when sending multi-media reply payloads, except PTT voice notes send the audio first and visible text separately because WhatsApp clients do not render voice-note captions consistently
- media source can be HTTP(S), `file://`, or local paths
@@ -511,10 +502,6 @@ Behavior notes:
<Accordion title="Linked but disconnected / reconnect loop">
Symptom: linked account with repeated disconnects or reconnect attempts.
Quiet accounts can stay connected past the normal message timeout; the watchdog
restarts when WhatsApp Web transport activity stops, the socket closes, or
application-level activity stays silent beyond the longer safety window.
Fix:
```bash
@@ -567,9 +554,7 @@ The effective `direct` map is determined first: if the account defines its own `
1. **Direct-specific system prompt** (`direct["<peerId>"].systemPrompt`): used when the specific peer entry exists in the map **and** its `systemPrompt` key is defined. If `systemPrompt` is an empty string (`""`), the wildcard is suppressed and no system prompt is applied.
2. **Direct wildcard system prompt** (`direct["*"].systemPrompt`): used when the specific peer entry is absent from the map entirely, or when it exists but defines no `systemPrompt` key.
<Note>
`dms` remains the lightweight per-DM history override bucket (`dms.<id>.historyLimit`). Prompt overrides live under `direct`.
</Note>
Note: `dms` remains the lightweight per-DM history override bucket (`dms.<id>.historyLimit`); prompt overrides live under `direct`.
**Difference from Telegram multi-account behavior:** In Telegram, root `groups` is intentionally suppressed for all accounts in a multi-account setup — even accounts that define no `groups` of their own — to prevent a bot from receiving group messages for groups it does not belong to. WhatsApp does not apply this guard: root `groups` and root `direct` are always inherited by accounts that define no account-level override, regardless of how many accounts are configured. In a multi-account WhatsApp setup, if you want per-account group or direct prompts, define the full map under each account explicitly rather than relying on root-level defaults.

View File

@@ -8,9 +8,7 @@ title: "Zalo personal"
Status: experimental. This integration automates a **personal Zalo account** via native `zca-js` inside OpenClaw.
<Warning>
This is an unofficial integration and may result in account suspension or ban. Use at your own risk.
</Warning>
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
## Bundled plugin

File diff suppressed because one or more lines are too long

View File

@@ -11,9 +11,9 @@ Manage isolated agents (workspaces + auth + routing).
Related:
- [Multi-agent routing](/concepts/multi-agent)
- [Agent workspace](/concepts/agent-workspace)
- [Skills config](/tools/skills-config): skill visibility configuration.
- Multi-agent routing: [Multi-Agent Routing](/concepts/multi-agent)
- Agent workspace: [Agent workspace](/concepts/agent-workspace)
- Skill visibility config: [Skills config](/tools/skills-config)
## Examples
@@ -34,7 +34,10 @@ openclaw agents delete work
Use routing bindings to pin inbound channel traffic to a specific agent.
If you also want different visible skills per agent, configure `agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See [Skills config](/tools/skills-config) and [Configuration reference](/gateway/config-agents#agents-defaults-skills).
If you also want different visible skills per agent, configure
`agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See
[Skills config](/tools/skills-config) and
[Configuration Reference](/gateway/config-agents#agents-defaults-skills).
List bindings:

View File

@@ -55,7 +55,6 @@ Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-
```bash
openclaw browser status
openclaw browser doctor
openclaw browser doctor --deep
openclaw browser start
openclaw browser start --headless
openclaw browser stop
@@ -64,8 +63,6 @@ openclaw browser --browser-profile openclaw reset-profile
Notes:
- `doctor --deep` adds a live snapshot probe. It is useful when basic CDP
readiness is green but you want proof that the current tab can be inspected.
- For `attachOnly` and remote CDP profiles, `openclaw browser stop` closes the
active control session and clears temporary emulation overrides even when
OpenClaw did not launch the browser process itself.

View File

@@ -12,7 +12,7 @@ Manage chat channel accounts and their runtime status on the Gateway.
Related docs:
- Channel guides: [Channels](/channels)
- Channel guides: [Channels](/channels/index)
- Gateway configuration: [Configuration](/gateway/configuration)
## Common commands
@@ -47,9 +47,7 @@ openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY"
openclaw channels remove --channel telegram --delete
```
<Tip>
`openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc).
</Tip>
Tip: `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc).
Common non-interactive add surfaces include:
@@ -61,8 +59,6 @@ Common non-interactive add surfaces include:
- Tlon fields: `--ship`, `--url`, `--code`, `--group-channels`, `--dm-allowlist`, `--auto-discover-channels`
- `--use-env` for default-account env-backed auth where supported
If a channel plugin needs to be installed during a flag-driven add command, OpenClaw uses the channel's default install source without opening the interactive plugin install prompt.
When you run `openclaw channels add` without flags, the interactive wizard can prompt:
- account ids per selected channel
@@ -83,15 +79,17 @@ Routing behavior stays consistent:
If your config was already in a mixed state (named accounts present and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into the promoted account chosen for that channel. Most channels promote into `accounts.default`; Matrix can preserve an existing named/default target instead.
## Login and logout (interactive)
## Login / logout (interactive)
```bash
openclaw channels login --channel whatsapp
openclaw channels logout --channel whatsapp
```
Notes:
- `channels login` supports `--verbose`.
- `channels login` and `logout` can infer the channel when only one supported login target is configured.
- `channels login` / `logout` can infer the channel when only one supported login target is configured.
## Troubleshooting

View File

@@ -3,18 +3,29 @@ summary: "CLI reference for `openclaw config` (get/set/unset/file/schema/validat
read_when:
- You want to read or edit config non-interactively
title: "Config"
sidebarTitle: "Config"
---
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`).
# `openclaw config`
## Root options
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/file/schema/validate
values by path and print the active config file. Run without a subcommand to
open the configure wizard (same as `openclaw configure`).
<ParamField path="--section <section>" type="string">
Repeatable guided-setup section filter when you run `openclaw config` without a subcommand.
</ParamField>
Root options:
Supported guided sections: `workspace`, `model`, `web`, `gateway`, `daemon`, `channels`, `plugins`, `skills`, `health`.
- `--section <section>`: repeatable guided-setup section filter when you run `openclaw config` without a subcommand
Supported guided sections:
- `workspace`
- `model`
- `web`
- `gateway`
- `daemon`
- `channels`
- `plugins`
- `skills`
- `health`
## Examples
@@ -41,19 +52,21 @@ openclaw config validate --json
Print the generated JSON schema for `openclaw.json` to stdout as JSON.
<AccordionGroup>
<Accordion title="What it includes">
- The current root config schema, plus a root `$schema` string field for editor tooling.
- Field `title` and `description` docs metadata used by the Control UI.
- Nested object, wildcard (`*`), and array-item (`[]`) nodes inherit the same `title` / `description` metadata when matching field documentation exists.
- `anyOf` / `oneOf` / `allOf` branches inherit the same docs metadata too when matching field documentation exists.
- Best-effort live plugin + channel schema metadata when runtime manifests can be loaded.
- A clean fallback schema even when the current config is invalid.
</Accordion>
<Accordion title="Related runtime RPC">
`config.schema.lookup` returns one normalized config path with a shallow schema node (`title`, `description`, `type`, `enum`, `const`, common bounds), matched UI hint metadata, and immediate child summaries. Use it for path-scoped drill-down in Control UI or custom clients.
</Accordion>
</AccordionGroup>
What it includes:
- The current root config schema, plus a root `$schema` string field for editor tooling
- Field `title` and `description` docs metadata used by the Control UI
- Nested object, wildcard (`*`), and array-item (`[]`) nodes inherit the same `title` / `description` metadata when matching field documentation exists
- `anyOf` / `oneOf` / `allOf` branches inherit the same docs metadata too when matching field documentation exists
- Best-effort live plugin + channel schema metadata when runtime manifests can be loaded
- A clean fallback schema even when the current config is invalid
Related runtime RPC:
- `config.schema.lookup` returns one normalized config path with a shallow
schema node (`title`, `description`, `type`, `enum`, `const`, common bounds),
matched UI hint metadata, and immediate child summaries. Use it for
path-scoped drill-down in Control UI or custom clients.
```bash
openclaw config schema
@@ -83,7 +96,8 @@ openclaw config set agents.list[1].tools.exec.node "node-id-or-name"
## Values
Values are parsed as JSON5 when possible; otherwise they are treated as strings. Use `--strict-json` to require JSON5 parsing. `--json` remains supported as a legacy alias.
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
Use `--strict-json` to require JSON5 parsing. `--json` remains supported as a legacy alias.
```bash
openclaw config set agents.defaults.heartbeat.every "0m"
@@ -93,9 +107,11 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
`config get <path> --json` prints the raw value as JSON instead of terminal-formatted text.
<Note>
Object assignment replaces the target path by default. Protected map/list paths that commonly hold user-added entries, such as `agents.defaults.models`, `models.providers`, `models.providers.<id>.models`, `plugins.entries`, and `auth.profiles`, refuse replacements that would remove existing entries unless you pass `--replace`.
</Note>
Object assignment replaces the target path by default. Protected map/list paths
that commonly hold user-added entries, such as `agents.defaults.models`,
`models.providers`, `models.providers.<id>.models`, `plugins.entries`, and
`auth.profiles`, refuse replacements that would remove existing entries unless
you pass `--replace`.
Use `--merge` when adding entries to those maps:
@@ -104,65 +120,59 @@ openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json
openclaw config set models.providers.ollama.models '[{"id":"llama3.2","name":"Llama 3.2"}]' --strict-json --merge
```
Use `--replace` only when you intentionally want the provided value to become the complete target value.
Use `--replace` only when you intentionally want the provided value to become
the complete target value.
## `config set` modes
`openclaw config set` supports four assignment styles:
<Tabs>
<Tab title="Value mode">
```bash
openclaw config set <path> <value>
```
</Tab>
<Tab title="SecretRef builder mode">
```bash
openclaw config set channels.discord.token \
--ref-provider default \
--ref-source env \
--ref-id DISCORD_BOT_TOKEN
```
</Tab>
<Tab title="Provider builder mode">
Provider builder mode targets `secrets.providers.<alias>` paths only:
1. Value mode: `openclaw config set <path> <value>`
2. SecretRef builder mode:
```bash
openclaw config set secrets.providers.vault \
--provider-source exec \
--provider-command /usr/local/bin/openclaw-vault \
--provider-arg read \
--provider-arg openai/api-key \
--provider-timeout-ms 5000
```
```bash
openclaw config set channels.discord.token \
--ref-provider default \
--ref-source env \
--ref-id DISCORD_BOT_TOKEN
```
</Tab>
<Tab title="Batch mode">
```bash
openclaw config set --batch-json '[
{
"path": "secrets.providers.default",
"provider": { "source": "env" }
},
{
"path": "channels.discord.token",
"ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" }
}
]'
```
3. Provider builder mode (`secrets.providers.<alias>` path only):
```bash
openclaw config set --batch-file ./config-set.batch.json --dry-run
```
```bash
openclaw config set secrets.providers.vault \
--provider-source exec \
--provider-command /usr/local/bin/openclaw-vault \
--provider-arg read \
--provider-arg openai/api-key \
--provider-timeout-ms 5000
```
</Tab>
</Tabs>
4. Batch mode (`--batch-json` or `--batch-file`):
<Warning>
SecretRef assignments are rejected on unsupported runtime-mutable surfaces (for example `hooks.token`, `commands.ownerDisplaySecret`, Discord thread-binding webhook tokens, and WhatsApp creds JSON). See [SecretRef Credential Surface](/reference/secretref-credential-surface).
</Warning>
```bash
openclaw config set --batch-json '[
{
"path": "secrets.providers.default",
"provider": { "source": "env" }
},
{
"path": "channels.discord.token",
"ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" }
}
]'
```
Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth. `--strict-json` / `--json` do not change batch parsing behavior.
```bash
openclaw config set --batch-file ./config-set.batch.json --dry-run
```
Policy note:
- SecretRef assignments are rejected on unsupported runtime-mutable surfaces (for example `hooks.token`, `commands.ownerDisplaySecret`, Discord thread-binding webhook tokens, and WhatsApp creds JSON). See [SecretRef Credential Surface](/reference/secretref-credential-surface).
Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth.
`--strict-json` / `--json` do not change batch parsing behavior.
JSON path/value mode remains supported for both SecretRefs and providers:
@@ -180,33 +190,34 @@ openclaw config set secrets.providers.vaultfile \
Provider builder targets must use `secrets.providers.<alias>` as the path.
<AccordionGroup>
<Accordion title="Common flags">
- `--provider-source <env|file|exec>`
- `--provider-timeout-ms <ms>` (`file`, `exec`)
</Accordion>
<Accordion title="Env provider (--provider-source env)">
- `--provider-allowlist <ENV_VAR>` (repeatable)
</Accordion>
<Accordion title="File provider (--provider-source file)">
- `--provider-path <path>` (required)
- `--provider-mode <singleValue|json>`
- `--provider-max-bytes <bytes>`
- `--provider-allow-insecure-path`
</Accordion>
<Accordion title="Exec provider (--provider-source exec)">
- `--provider-command <path>` (required)
- `--provider-arg <arg>` (repeatable)
- `--provider-no-output-timeout-ms <ms>`
- `--provider-max-output-bytes <bytes>`
- `--provider-json-only`
- `--provider-env <KEY=VALUE>` (repeatable)
- `--provider-pass-env <ENV_VAR>` (repeatable)
- `--provider-trusted-dir <path>` (repeatable)
- `--provider-allow-insecure-path`
- `--provider-allow-symlink-command`
</Accordion>
</AccordionGroup>
Common flags:
- `--provider-source <env|file|exec>`
- `--provider-timeout-ms <ms>` (`file`, `exec`)
Env provider (`--provider-source env`):
- `--provider-allowlist <ENV_VAR>` (repeatable)
File provider (`--provider-source file`):
- `--provider-path <path>` (required)
- `--provider-mode <singleValue|json>`
- `--provider-max-bytes <bytes>`
- `--provider-allow-insecure-path`
Exec provider (`--provider-source exec`):
- `--provider-command <path>` (required)
- `--provider-arg <arg>` (repeatable)
- `--provider-no-output-timeout-ms <ms>`
- `--provider-max-output-bytes <bytes>`
- `--provider-json-only`
- `--provider-env <KEY=VALUE>` (repeatable)
- `--provider-pass-env <ENV_VAR>` (repeatable)
- `--provider-trusted-dir <path>` (repeatable)
- `--provider-allow-insecure-path`
- `--provider-allow-symlink-command`
Hardened exec provider example:
@@ -248,29 +259,25 @@ openclaw config set channels.discord.token \
--allow-exec
```
<AccordionGroup>
<Accordion title="Dry-run behavior">
- Builder mode: runs SecretRef resolvability checks for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks.
- Policy validation also runs for known unsupported SecretRef target surfaces.
- Policy checks evaluate the full post-change config, so parent-object writes (for example setting `hooks` as an object) cannot bypass unsupported-surface validation.
- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects.
- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands).
- `--allow-exec` is dry-run only and errors if used without `--dry-run`.
</Accordion>
<Accordion title="--dry-run --json fields">
`--dry-run --json` prints a machine-readable report:
Dry-run behavior:
- `ok`: whether dry-run passed
- `operations`: number of assignments evaluated
- `checks`: whether schema/resolvability checks ran
- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped)
- `refsChecked`: number of refs actually resolved during dry-run
- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set
- `errors`: structured schema/resolvability failures when `ok=false`
- Builder mode: runs SecretRef resolvability checks for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks.
- Policy validation also runs for known unsupported SecretRef target surfaces.
- Policy checks evaluate the full post-change config, so parent-object writes (for example setting `hooks` as an object) cannot bypass unsupported-surface validation.
- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects.
- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands).
- `--allow-exec` is dry-run only and errors if used without `--dry-run`.
</Accordion>
</AccordionGroup>
`--dry-run --json` prints a machine-readable report:
- `ok`: whether dry-run passed
- `operations`: number of assignments evaluated
- `checks`: whether schema/resolvability checks ran
- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped)
- `refsChecked`: number of refs actually resolved during dry-run
- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set
- `errors`: structured schema/resolvability failures when `ok=false`
### JSON output shape
@@ -297,67 +304,66 @@ openclaw config set channels.discord.token \
}
```
<Tabs>
<Tab title="Success example">
```json
{
"ok": true,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0
}
```
</Tab>
<Tab title="Failure example">
```json
{
"ok": false,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0,
"errors": [
{
"kind": "resolvability",
"message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.",
"ref": "env:default:MISSING_TEST_SECRET"
}
]
}
```
</Tab>
</Tabs>
Success example:
<AccordionGroup>
<Accordion title="If dry-run fails">
- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape.
- `Config policy validation failed: unsupported SecretRef usage`: move that credential back to plaintext/string input and keep SecretRefs on supported surfaces only.
- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation.
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
</Accordion>
</AccordionGroup>
```json
{
"ok": true,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0
}
```
Failure example:
```json
{
"ok": false,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0,
"errors": [
{
"kind": "resolvability",
"message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.",
"ref": "env:default:MISSING_TEST_SECRET"
}
]
}
```
If dry-run fails:
- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape.
- `Config policy validation failed: unsupported SecretRef usage`: move that credential back to plaintext/string input and keep SecretRefs on supported surfaces only.
- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation.
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
## Write safety
`openclaw config set` and other OpenClaw-owned config writers validate the full post-change config before committing it to disk. If the new payload fails schema validation or looks like a destructive clobber, the active config is left alone and the rejected payload is saved beside it as `openclaw.json.rejected.*`.
<Warning>
The active config path must be a regular file. Symlinked `openclaw.json` layouts are unsupported for writes; use `OPENCLAW_CONFIG_PATH` to point directly at the real file instead.
</Warning>
`openclaw config set` and other OpenClaw-owned config writers validate the full
post-change config before committing it to disk. If the new payload fails schema
validation or looks like a destructive clobber, the active config is left alone
and the rejected payload is saved beside it as `openclaw.json.rejected.*`.
The active config path must be a regular file. Symlinked `openclaw.json`
layouts are unsupported for writes; use `OPENCLAW_CONFIG_PATH` to point directly
at the real file instead.
Prefer CLI writes for small edits:
@@ -375,9 +381,19 @@ ls -lt "$CONFIG".rejected.* 2>/dev/null | head
openclaw config validate
```
Direct editor writes are still allowed, but the running Gateway treats them as untrusted until they validate. Invalid direct edits can be restored from the last-known-good backup during startup or hot reload. See [Gateway troubleshooting](/gateway/troubleshooting#gateway-restored-last-known-good-config).
Direct editor writes are still allowed, but the running Gateway treats them as
untrusted until they validate. Invalid direct edits can be restored from the
last-known-good backup during startup or hot reload. See
[Gateway troubleshooting](/gateway/troubleshooting#gateway-restored-last-known-good-config).
Whole-file recovery is reserved for globally broken config, such as parse errors, root-level schema failures, legacy migration failures, or mixed plugin and root failures. If validation fails only under `plugins.entries.<id>...`, OpenClaw keeps the active `openclaw.json` in place and reports the plugin-local issue instead of restoring `.last-good`. This prevents plugin schema changes or `minHostVersion` skew from rolling back unrelated user settings such as models, providers, auth profiles, channels, gateway exposure, tools, memory, browser, or cron config.
Whole-file recovery is reserved for globally broken config, such as parse
errors, root-level schema failures, legacy migration failures, or mixed plugin
and root failures. If validation fails only under `plugins.entries.<id>...`,
OpenClaw keeps the active `openclaw.json` in place and reports the plugin-local
issue instead of restoring `.last-good`. This prevents plugin schema changes or
`minHostVersion` skew from rolling back unrelated user settings such as models,
providers, auth profiles, channels, gateway exposure, tools, memory, browser, or
cron config.
## Subcommands
@@ -387,18 +403,21 @@ Restart the gateway after edits.
## Validate
Validate the current config against the active schema without starting the gateway.
Validate the current config against the active schema without starting the
gateway.
```bash
openclaw config validate
openclaw config validate --json
```
After `openclaw config validate` is passing, you can use the local TUI to have an embedded agent compare the active config against the docs while you validate each change from the same terminal:
After `openclaw config validate` is passing, you can use the local TUI to have
an embedded agent compare the active config against the docs while you validate
each change from the same terminal:
<Note>
If validation is already failing, start with `openclaw configure` or `openclaw doctor --fix`. `openclaw chat` does not bypass the invalid-config guard.
</Note>
If validation is already failing, start with `openclaw configure` or
`openclaw doctor --fix`. `openclaw chat` does not bypass the invalid-config
guard.
```bash
openclaw chat
@@ -415,20 +434,10 @@ Then inside the TUI:
Typical repair loop:
<Steps>
<Step title="Compare with docs">
Ask the agent to compare your current config with the relevant docs page and suggest the smallest fix.
</Step>
<Step title="Apply targeted edits">
Apply targeted edits with `openclaw config set` or `openclaw configure`.
</Step>
<Step title="Re-validate">
Rerun `openclaw config validate` after each change.
</Step>
<Step title="Doctor for runtime issues">
If validation passes but the runtime is still unhealthy, run `openclaw doctor` or `openclaw doctor --fix` for migration and repair help.
</Step>
</Steps>
- Ask the agent to compare your current config with the relevant docs page and suggest the smallest fix.
- Apply targeted edits with `openclaw config set` or `openclaw configure`.
- Rerun `openclaw config validate` after each change.
- If validation passes but the runtime is still unhealthy, run `openclaw doctor` or `openclaw doctor --fix` for migration and repair help.
## Related

View File

@@ -9,15 +9,23 @@ title: "Configure"
Interactive prompt to set up credentials, devices, and agent defaults.
<Note>
The **Model** section includes a multi-select for the `agents.defaults.models` allowlist (what shows up in `/model` and the model picker). Provider-scoped setup choices merge their selected models into the existing allowlist instead of replacing unrelated providers already in the config. Re-running provider auth from configure preserves an existing `agents.defaults.model.primary`. Use `openclaw models auth login --provider <id> --set-default` or `openclaw models set <model>` when you intentionally want to change the default model.
</Note>
Note: The **Model** section now includes a multi-select for the
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
Provider-scoped setup choices merge their selected models into the existing
allowlist instead of replacing unrelated providers already in the config.
Re-running provider auth from configure preserves an existing
`agents.defaults.model.primary`; use `openclaw models auth login --provider <id> --set-default`
or `openclaw models set <model>` when you intentionally want to change the default model.
When configure starts from a provider auth choice, the default-model and allowlist pickers prefer that provider automatically. For paired providers such as Volcengine and BytePlus, the same preference also matches their coding-plan variants (`volcengine-plan/*`, `byteplus-plan/*`). If the preferred-provider filter would produce an empty list, configure falls back to the unfiltered catalog instead of showing a blank picker.
When configure starts from a provider auth choice, the default-model and
allowlist pickers prefer that provider automatically. For paired providers such
as Volcengine/BytePlus, the same preference also matches their coding-plan
variants (`volcengine-plan/*`, `byteplus-plan/*`). If the preferred-provider
filter would produce an empty list, configure falls back to the unfiltered
catalog instead of showing a blank picker.
<Tip>
`openclaw config` without a subcommand opens the same wizard. Use `openclaw config get|set|unset` for non-interactive edits.
</Tip>
Tip: `openclaw config` without a subcommand opens the same wizard. Use
`openclaw config get|set|unset` for non-interactive edits.
For web search, `openclaw configure --section web` lets you choose a provider
and configure its credentials. Some providers also show provider-specific

View File

@@ -162,7 +162,7 @@ configured OpenClaw model. If no configured model is usable yet, it can fall
back to local runtimes already present on the machine:
- Claude Code CLI: `claude-cli/claude-opus-4-7`
- Codex app-server harness: `openai/gpt-5.5` with `agentRuntime.id: "codex"`
- Codex app-server harness: `openai/gpt-5.5` with `embeddedHarness.runtime: "codex"`
- Codex CLI: `codex-cli/gpt-5.5`
The model-assisted planner cannot mutate config directly. It must translate the

View File

@@ -2,7 +2,7 @@
summary: "CLI reference for `openclaw cron` (schedule and run background jobs)"
read_when:
- You want scheduled jobs and wakeups
- You are debugging cron execution and logs
- Youre debugging cron execution and logs
title: "Cron"
---
@@ -10,138 +10,86 @@ title: "Cron"
Manage cron jobs for the Gateway scheduler.
<Tip>
Run `openclaw cron --help` for the full command surface. See [Cron jobs](/automation/cron-jobs) for the conceptual guide.
</Tip>
Related:
## Sessions
- Cron jobs: [Cron jobs](/automation/cron-jobs)
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
Tip: run `openclaw cron --help` for the full command surface.
<AccordionGroup>
<Accordion title="Session keys">
- `main` binds to the agent's main session.
- `isolated` creates a fresh transcript and session id for each run.
- `current` binds to the active session at creation time.
- `session:<id>` pins to an explicit persistent session key.
</Accordion>
<Accordion title="Isolated session semantics">
Isolated runs reset ambient conversation context. Channel and group routing, send/queue policy, elevation, origin, and ACP runtime binding are reset for the new run. Safe preferences and explicit user-selected model or auth overrides can carry across runs.
</Accordion>
</AccordionGroup>
Note: `openclaw cron list` and `openclaw cron show <job-id>` preview the
resolved delivery route. For `channel: "last"`, the preview shows whether the
route resolved from the main/current session or will fail closed.
## Delivery
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
output internal. `--deliver` remains as a deprecated alias for `--announce`.
`openclaw cron list` and `openclaw cron show <job-id>` preview the resolved delivery route. For `channel: "last"`, the preview shows whether the route resolved from the main or current session, or will fail closed.
Note: isolated cron chat delivery is shared. `--announce` is runner fallback
delivery for the final reply; `--no-deliver` disables that fallback but does
not remove the agent's `message` tool when a chat route is available.
<Note>
Isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep output internal. `--deliver` remains as a deprecated alias for `--announce`.
</Note>
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
### Delivery ownership
Note: `--session` supports `main`, `isolated`, `current`, and `session:<id>`.
Use `current` to bind to the active session at creation time, or `session:<id>` for
an explicit persistent session key.
Isolated cron chat delivery is shared between the agent and the runner:
Note: `--session isolated` creates a fresh transcript/session id for each run.
Safe preferences and explicit user-selected model/auth overrides can carry, but
ambient conversation context does not: channel/group routing, send/queue policy,
elevation, origin, and ACP runtime binding are reset for the new isolated run.
- The agent can send directly using the `message` tool when a chat route is available.
- `announce` fallback-delivers the final reply only when the agent did not send directly to the resolved target.
- `webhook` posts the finished payload to a URL.
- `none` disables runner fallback delivery.
Note: for one-shot CLI jobs, offset-less `--at` datetimes are treated as UTC unless you also pass
`--tz <iana>`, which interprets that local wall-clock time in the given timezone.
`--announce` is runner fallback delivery for the final reply. `--no-deliver` disables that fallback but does not remove the agent's `message` tool when a chat route is available.
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
Reminders created from an active chat preserve the live chat delivery target for fallback announce delivery. Internal session keys may be lowercase; do not use them as a source of truth for case-sensitive provider IDs such as Matrix room IDs.
Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id <job-id>` to follow the eventual outcome.
### Failure delivery
Note: `openclaw cron run <job-id>` force-runs by default. Use `--due` to keep the
older "only run if due" behavior.
Failure notifications resolve in this order:
Note: isolated cron turns suppress stale acknowledgement-only replies. If the
first result is just an interim status update and no descendant subagent run is
responsible for the eventual answer, cron re-prompts once for the real result
before delivery.
1. `delivery.failureDestination` on the job.
2. Global `cron.failureDestination`.
3. The job's primary announce target (when no explicit failure destination is set).
Note: if an isolated cron run returns only the silent token (`NO_REPLY` /
`no_reply`), cron suppresses direct outbound delivery and the fallback queued
summary path as well, so nothing is posted back to chat.
<Note>
Main-session jobs may only use `delivery.failureDestination` when primary delivery mode is `webhook`. Isolated jobs accept it in all modes.
</Note>
Note: `cron add|edit --model ...` uses that selected allowed model for the job.
If the model is not allowed, cron warns and falls back to the job's agent/default
model selection instead. Configured fallback chains still apply, but a plain
model override with no explicit per-job fallback list no longer appends the
agent primary as a hidden extra retry target.
Note: isolated cron runs treat run-level agent failures as job errors even when
no reply payload is produced, so model/provider failures still increment error
counters and trigger failure notifications.
Note: isolated cron model precedence is Gmail-hook override first, then per-job
`--model`, then any user-selected stored cron-session model override, then the
normal agent/default selection.
## Scheduling
Note: isolated cron fast mode follows the resolved live model selection. Model
config `params.fastMode` applies by default, but a stored session `fastMode`
override still wins over config.
### One-shot jobs
Note: if an isolated run throws `LiveSessionModelSwitchError`, cron persists the
switched provider/model (and switched auth profile override when present) for
the active run before retrying. The outer retry loop is bounded to 2 switch
retries after the initial attempt, then aborts instead of looping forever.
`--at <datetime>` schedules a one-shot run. Offset-less datetimes are treated as UTC unless you also pass `--tz <iana>`, which interprets the wall-clock time in the given timezone.
Note: failure notifications use `delivery.failureDestination` first, then
global `cron.failureDestination`, and finally fall back to the job's primary
announce target when no explicit failure destination is configured.
<Note>
One-shot jobs delete after success by default. Use `--keep-after-run` to preserve them.
</Note>
### Recurring jobs
Recurring jobs use exponential retry backoff after consecutive errors: 30s, 1m, 5m, 15m, 60m. The schedule returns to normal after the next successful run.
### Manual runs
`openclaw cron run` returns as soon as the manual run is queued. Successful responses include `{ ok: true, enqueued: true, runId }`. Use `openclaw cron runs --id <job-id>` to follow the eventual outcome.
<Note>
`openclaw cron run <job-id>` force-runs by default. Use `--due` to keep the older "only run if due" behavior.
</Note>
## Models
`cron add|edit --model <ref>` selects an allowed model for the job.
<Warning>
If the model is not allowed, cron warns and falls back to the job's agent or default model selection. Configured fallback chains still apply, but a plain model override with no explicit per-job fallback list no longer appends the agent primary as a hidden extra retry target.
</Warning>
### Isolated cron model precedence
Isolated cron resolves the active model in this order:
1. Gmail-hook override.
2. Per-job `--model`.
3. Stored cron-session model override (when the user selected one).
4. Agent or default model selection.
### Fast mode
Isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config.
### Live model switch retries
If an isolated run throws `LiveSessionModelSwitchError`, cron persists the switched provider and model (and switched auth profile override when present) for the active run before retrying. The outer retry loop is bounded to two switch retries after the initial attempt, then aborts instead of looping forever.
## Run output and denials
### Stale acknowledgement suppression
Isolated cron turns suppress stale acknowledgement-only replies. If the first result is just an interim status update and no descendant subagent run is responsible for the eventual answer, cron re-prompts once for the real result before delivery.
### Silent token suppression
If an isolated cron run returns only the silent token (`NO_REPLY` or `no_reply`), cron suppresses both direct outbound delivery and the fallback queued summary path, so nothing is posted back to chat.
### Structured denials
Isolated cron runs prefer structured execution-denial metadata from the embedded run, then fall back to known denial markers in final output, such as `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusal phrases.
`cron list` and run history surface the denial reason instead of reporting a blocked command as `ok`.
## Retention
Retention and pruning are controlled in config:
Note: retention/pruning is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.maxBytes` and `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
## Migrating older jobs
<Note>
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates simple `notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is configured.
</Note>
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates simple
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
configured.
## Common edits
@@ -183,9 +131,21 @@ openclaw cron add \
`--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set.
Delivery ownership note:
- Isolated cron chat delivery is shared. The agent can send directly with the
`message` tool when a chat route is available.
- `announce` fallback-delivers the final reply only when the agent did not send
directly to the resolved target. `webhook` posts the finished payload to a URL.
`none` disables runner fallback delivery.
- Reminders created from an active chat preserve the live chat delivery target
for fallback announce delivery. Internal session keys may be lowercase; do not
use them as a source of truth for case-sensitive provider IDs such as Matrix
room IDs.
## Common admin commands
Manual run and inspection:
Manual run:
```bash
openclaw cron list
@@ -195,9 +155,10 @@ openclaw cron run <job-id> --due
openclaw cron runs --id <job-id> --limit 50
```
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
`cron runs` entries include delivery diagnostics with the intended cron target,
the resolved target, message-tool sends, fallback use, and delivered state.
Agent and session retargeting:
Agent/session retargeting:
```bash
openclaw cron edit <job-id> --agent ops
@@ -215,6 +176,14 @@ openclaw cron edit <job-id> --no-best-effort-deliver
openclaw cron edit <job-id> --no-deliver
```
Failure-delivery note:
- `delivery.failureDestination` is supported for isolated jobs.
- Main-session jobs may only use `delivery.failureDestination` when primary
delivery mode is `webhook`.
- If you do not set any failure destination and the job already announces to a
channel, failure notifications reuse that same announce target.
## Related
- [CLI reference](/cli)

View File

@@ -55,9 +55,10 @@ is omitted or `--latest` is passed, OpenClaw only prints the selected pending
request and exits; rerun approval with the exact request ID after verifying
the details.
<Note>
If a device retries pairing with changed auth details (role, scopes, or public key), OpenClaw supersedes the previous pending entry and issues a new `requestId`. Run `openclaw devices list` right before approval to use the current ID.
</Note>
Note: if a device retries pairing with changed auth details (role/scopes/public
key), OpenClaw supersedes the previous pending entry and issues a new
`requestId`. Run `openclaw devices list` right before approval to use the
current ID.
If the device is already paired and asks for broader scopes or a broader role,
OpenClaw keeps the existing approval in place and creates a new pending upgrade
@@ -94,9 +95,9 @@ If you omit `--scope`, later reconnects with the stored rotated token reuse that
token's cached approved scopes. If you pass explicit `--scope` values, those
become the stored scope set for future cached-token reconnects.
Non-admin paired-device callers can rotate only their **own** device token.
The target token scope set must stay within the caller session's own operator
scopes; rotation cannot mint or preserve a broader operator token than the
caller already has.
Also, any explicit `--scope` values must stay within the caller session's own
operator scopes; rotation cannot mint a broader operator token than the caller
already has.
```
openclaw devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
@@ -110,8 +111,6 @@ Revoke a device token for a specific role.
Non-admin paired-device callers can revoke only their **own** device token.
Revoking some other device's token requires `operator.admin`.
The target token scope set must also fit within the caller session's own
operator scopes; pairing-only callers cannot revoke admin/write operator tokens.
```
openclaw devices revoke --device <deviceId> --role node
@@ -127,9 +126,8 @@ Returns the revoke result as JSON.
- `--timeout <ms>`: RPC timeout.
- `--json`: JSON output (recommended for scripting).
<Warning>
When you set `--url`, the CLI does not fall back to config or environment credentials. Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
</Warning>
Note: when you set `--url`, the CLI does not fall back to config or environment credentials.
Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
## Notes
@@ -137,15 +135,12 @@ When you set `--url`, the CLI does not fall back to config or environment creden
- These commands require `operator.pairing` (or `operator.admin`) scope.
- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for
fresh node device pairing only; it does not change CLI approval authority.
- Token rotation and revocation stay inside the approved pairing role set and
approved scope baseline for that device. A stray cached token entry does not
grant a token-management target.
- Token rotation stays inside the approved pairing role set and approved scope
baseline for that device. A stray cached token entry does not grant a new
rotate target.
- For paired-device token sessions, cross-device management is admin-only:
`remove`, `rotate`, and `revoke` are self-only unless the caller has
`operator.admin`.
- Token mutation is also caller-scope contained: a pairing-only session cannot
rotate or revoke a token that currently carries `operator.admin` or
`operator.write`.
- `devices clear` is intentionally gated by `--yes`.
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
- `devices approve` requires an explicit request ID before minting tokens; omitting `requestId` or passing `--latest` only previews the newest pending request.

View File

@@ -44,7 +44,6 @@ Notes:
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`.
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.

View File

@@ -5,22 +5,19 @@ read_when:
- Debugging Gateway auth, bind modes, and connectivity
- Discovering gateways via Bonjour (local + wide-area DNS-SD)
title: "Gateway"
sidebarTitle: "Gateway"
---
The Gateway is OpenClaw's WebSocket server (channels, nodes, sessions, hooks). Subcommands in this page live under `openclaw gateway …`.
# Gateway CLI
<CardGroup cols={3}>
<Card title="Bonjour discovery" href="/gateway/bonjour">
Local mDNS + wide-area DNS-SD setup.
</Card>
<Card title="Discovery overview" href="/gateway/discovery">
How OpenClaw advertises and finds gateways.
</Card>
<Card title="Configuration" href="/gateway/configuration">
Top-level gateway config keys.
</Card>
</CardGroup>
The Gateway is OpenClaws WebSocket server (channels, nodes, sessions, hooks).
Subcommands in this page live under `openclaw gateway …`.
Related docs:
- [/gateway/bonjour](/gateway/bonjour)
- [/gateway/discovery](/gateway/discovery)
- [/gateway/configuration](/gateway/configuration)
## Run the Gateway
@@ -36,79 +33,37 @@ Foreground alias:
openclaw gateway run
```
<AccordionGroup>
<Accordion title="Startup behavior">
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly.
- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to "guess local" for you.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don't restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
</Accordion>
</AccordionGroup>
Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly.
- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to guess local for you.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they dont restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options
<ParamField path="--port <port>" type="number">
WebSocket port (default comes from config/env; usually `18789`).
</ParamField>
<ParamField path="--bind <loopback|lan|tailnet|auto|custom>" type="string">
Listener bind mode.
</ParamField>
<ParamField path="--auth <token|password>" type="string">
Auth mode override.
</ParamField>
<ParamField path="--token <token>" type="string">
Token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
</ParamField>
<ParamField path="--password <password>" type="string">
Password override.
</ParamField>
<ParamField path="--password-file <path>" type="string">
Read the gateway password from a file.
</ParamField>
<ParamField path="--tailscale <off|serve|funnel>" type="string">
Expose the Gateway via Tailscale.
</ParamField>
<ParamField path="--tailscale-reset-on-exit" type="boolean">
Reset Tailscale serve/funnel config on shutdown.
</ParamField>
<ParamField path="--allow-unconfigured" type="boolean">
Allow gateway start without `gateway.mode=local` in config. Bypasses the startup guard for ad-hoc/dev bootstrap only; does not write or repair the config file.
</ParamField>
<ParamField path="--dev" type="boolean">
Create a dev config + workspace if missing (skips BOOTSTRAP.md).
</ParamField>
<ParamField path="--reset" type="boolean">
Reset dev config + credentials + sessions + workspace (requires `--dev`).
</ParamField>
<ParamField path="--force" type="boolean">
Kill any existing listener on the selected port before starting.
</ParamField>
<ParamField path="--verbose" type="boolean">
Verbose logs.
</ParamField>
<ParamField path="--cli-backend-logs" type="boolean">
Only show CLI backend logs in the console (and enable stdout/stderr).
</ParamField>
<ParamField path="--ws-log <auto|full|compact>" type="string" default="auto">
Websocket log style.
</ParamField>
<ParamField path="--compact" type="boolean">
Alias for `--ws-log compact`.
</ParamField>
<ParamField path="--raw-stream" type="boolean">
Log raw model stream events to jsonl.
</ParamField>
<ParamField path="--raw-stream-path <path>" type="string">
Raw stream jsonl path.
</ParamField>
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode.
- `--auth <token|password>`: auth mode override.
- `--token <token>`: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
- `--password <password>`: password override. Warning: inline passwords can be exposed in local process listings.
- `--password-file <path>`: read the gateway password from a file.
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. This bypasses the startup guard for ad-hoc/dev bootstrap only; it does not write or repair the config file.
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
- `--force`: kill any existing listener on the selected port before starting.
- `--verbose`: verbose logs.
- `--cli-backend-logs`: only show CLI backend logs in the console (and enable stdout/stderr).
- `--ws-log <auto|full|compact>`: websocket log style (default `auto`).
- `--compact`: alias for `--ws-log compact`.
- `--raw-stream`: log raw model stream events to jsonl.
- `--raw-stream-path <path>`: raw stream jsonl path.
<Warning>
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
</Warning>
### Startup profiling
Startup profiling:
- Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup.
- Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, and startup trace timings.
@@ -117,24 +72,22 @@ Inline `--password` can be exposed in local process listings. Prefer `--password
All query commands use WebSocket RPC.
<Tabs>
<Tab title="Output modes">
- Default: human-readable (colored in TTY).
- `--json`: machine-readable JSON (no styling/spinner).
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
</Tab>
<Tab title="Shared options">
- `--url <url>`: Gateway WebSocket URL.
- `--token <token>`: Gateway token.
- `--password <password>`: Gateway password.
- `--timeout <ms>`: timeout/budget (varies per command).
- `--expect-final`: wait for a "final" response (agent calls).
</Tab>
</Tabs>
Output modes:
<Note>
When you set `--url`, the CLI does not fall back to config or environment credentials. Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
</Note>
- Default: human-readable (colored in TTY).
- `--json`: machine-readable JSON (no styling/spinner).
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
Shared options (where supported):
- `--url <url>`: Gateway WebSocket URL.
- `--token <token>`: Gateway token.
- `--password <password>`: Gateway password.
- `--timeout <ms>`: timeout/budget (varies per command).
- `--expect-final`: wait for a “final” response (agent calls).
Note: when you set `--url`, the CLI does not fall back to config or environment credentials.
Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
### `gateway health`
@@ -154,9 +107,9 @@ openclaw gateway usage-cost --days 7
openclaw gateway usage-cost --json
```
<ParamField path="--days <days>" type="number" default="30">
Number of days to include.
</ParamField>
Options:
- `--days <days>`: number of days to include (default `30`).
### `gateway stability`
@@ -170,35 +123,24 @@ openclaw gateway stability --bundle latest --export
openclaw gateway stability --json
```
<ParamField path="--limit <limit>" type="number" default="25">
Maximum number of recent events to include (max `1000`).
</ParamField>
<ParamField path="--type <type>" type="string">
Filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
</ParamField>
<ParamField path="--since-seq <seq>" type="number">
Include only events after a diagnostic sequence number.
</ParamField>
<ParamField path="--bundle [path]" type="string">
Read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
</ParamField>
<ParamField path="--export" type="boolean">
Write a shareable support diagnostics zip instead of printing stability details.
</ParamField>
<ParamField path="--output <path>" type="string">
Output path for `--export`.
</ParamField>
Options:
<AccordionGroup>
<Accordion title="Privacy and bundle behavior">
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely.
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
</Accordion>
</AccordionGroup>
- `--limit <limit>`: maximum number of recent events to include (default `25`, max `1000`).
- `--type <type>`: filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
- `--since-seq <seq>`: include only events after a diagnostic sequence number.
- `--bundle [path]`: read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
- `--export`: write a shareable support diagnostics zip instead of printing stability details.
- `--output <path>`: output path for `--export`.
Notes:
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely.
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
### `gateway diagnostics export`
Write a local diagnostics zip that is designed to attach to bug reports. For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics).
Write a local diagnostics zip that is designed to attach to bug reports.
For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics).
```bash
openclaw gateway diagnostics export
@@ -206,33 +148,17 @@ openclaw gateway diagnostics export --output openclaw-diagnostics.zip
openclaw gateway diagnostics export --json
```
<ParamField path="--output <path>" type="string">
Output zip path. Defaults to a support export under the state directory.
</ParamField>
<ParamField path="--log-lines <count>" type="number" default="5000">
Maximum sanitized log lines to include.
</ParamField>
<ParamField path="--log-bytes <bytes>" type="number" default="1000000">
Maximum log bytes to inspect.
</ParamField>
<ParamField path="--url <url>" type="string">
Gateway WebSocket URL for the health snapshot.
</ParamField>
<ParamField path="--token <token>" type="string">
Gateway token for the health snapshot.
</ParamField>
<ParamField path="--password <password>" type="string">
Gateway password for the health snapshot.
</ParamField>
<ParamField path="--timeout <ms>" type="number" default="3000">
Status/health snapshot timeout.
</ParamField>
<ParamField path="--no-stability-bundle" type="boolean">
Skip persisted stability bundle lookup.
</ParamField>
<ParamField path="--json" type="boolean">
Print the written path, size, and manifest as JSON.
</ParamField>
Options:
- `--output <path>`: output zip path. Defaults to a support export under the state directory.
- `--log-lines <count>`: maximum sanitized log lines to include (default `5000`).
- `--log-bytes <bytes>`: maximum log bytes to inspect (default `1000000`).
- `--url <url>`: Gateway WebSocket URL for the health snapshot.
- `--token <token>`: Gateway token for the health snapshot.
- `--password <password>`: Gateway password for the health snapshot.
- `--timeout <ms>`: status/health snapshot timeout (default `3000`).
- `--no-stability-bundle`: skip persisted stability bundle lookup.
- `--json`: print the written path, size, and manifest as JSON.
The export contains a manifest, a Markdown summary, config shape, sanitized config details, sanitized log summaries, sanitized Gateway status/health snapshots, and the newest stability bundle when one exists.
@@ -248,113 +174,93 @@ openclaw gateway status --json
openclaw gateway status --require-rpc
```
<ParamField path="--url <url>" type="string">
Add an explicit probe target. Configured remote + localhost are still probed.
</ParamField>
<ParamField path="--token <token>" type="string">
Token auth for the probe.
</ParamField>
<ParamField path="--password <password>" type="string">
Password auth for the probe.
</ParamField>
<ParamField path="--timeout <ms>" type="number" default="10000">
Probe timeout.
</ParamField>
<ParamField path="--no-probe" type="boolean">
Skip the connectivity probe (service-only view).
</ParamField>
<ParamField path="--deep" type="boolean">
Scan system-level services too.
</ParamField>
<ParamField path="--require-rpc" type="boolean">
Upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`.
</ParamField>
Options:
<AccordionGroup>
<Accordion title="Status semantics">
- `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid.
- Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations.
- Diagnostic probes are non-mutating for first-time device auth: they reuse an existing cached device token when one exists, but they do not create a new CLI device identity or read-only device pairing record just to check status.
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too.
- `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine.
- Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift.
</Accordion>
<Accordion title="Linux systemd auth-drift checks">
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
- Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback).
- If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution.
</Accordion>
</AccordionGroup>
- `--url <url>`: add an explicit probe target. Configured remote + localhost are still probed.
- `--token <token>`: token auth for the probe.
- `--password <password>`: password auth for the probe.
- `--timeout <ms>`: probe timeout (default `10000`).
- `--no-probe`: skip the connectivity probe (service-only view).
- `--deep`: scan system-level services too.
- `--require-rpc`: upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`.
Notes:
- `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid.
- Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations.
- Diagnostic probes are non-mutating for first-time device auth: they reuse an
existing cached device token when one exists, but they do not create a new CLI
device identity or read-only device pairing record just to check status.
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too.
- `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine.
- Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift.
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
- Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback).
- If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution.
### `gateway probe`
`gateway probe` is the "debug everything" command. It always probes:
`gateway probe` is the debug everything command. It always probes:
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If you pass `--url`, that explicit target is added ahead of both. Human output labels the targets as:
If you pass `--url`, that explicit target is added ahead of both. Human output labels the
targets as:
- `URL (explicit)`
- `Remote (configured)` or `Remote (configured, inactive)`
- `Local loopback`
<Note>
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
</Note>
```bash
openclaw gateway probe
openclaw gateway probe --json
```
<AccordionGroup>
<Accordion title="Interpretation">
- `Reachable: yes` means at least one target accepted a WebSocket connect.
- `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability.
- `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
- `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure.
- Like `gateway status`, probe reuses existing cached device auth but does not create first-time device identity or pairing state.
- Exit code is non-zero only when no probed target is reachable.
</Accordion>
<Accordion title="JSON output">
Top level:
Interpretation:
- `ok`: at least one target is reachable.
- `degraded`: at least one target had scope-limited detail RPC.
- `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`).
- `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback.
- `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`.
- `network`: local loopback/tailnet URL hints derived from current config and host networking.
- `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass.
- `Reachable: yes` means at least one target accepted a WebSocket connect.
- `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability.
- `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
- `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure.
- Like `gateway status`, probe reuses existing cached device auth but does not
create first-time device identity or pairing state.
- Exit code is non-zero only when no probed target is reachable.
Per target (`targets[].connect`):
JSON notes (`--json`):
- `ok`: reachability after connect + degraded classification.
- `rpcOk`: full detail RPC success.
- `scopeLimited`: detail RPC failed due to missing operator scope.
- Top level:
- `ok`: at least one target is reachable.
- `degraded`: at least one target had scope-limited detail RPC.
- `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`).
- `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback.
- `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`.
- `network`: local loopback/tailnet URL hints derived from current config and host networking.
- `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass.
- Per target (`targets[].connect`):
- `ok`: reachability after connect + degraded classification.
- `rpcOk`: full detail RPC success.
- `scopeLimited`: detail RPC failed due to missing operator scope.
- Per target (`targets[].auth`):
- `role`: auth role reported in `hello-ok` when available.
- `scopes`: granted scopes reported in `hello-ok` when available.
- `capability`: the surfaced auth capability classification for that target.
Per target (`targets[].auth`):
Common warning codes:
- `role`: auth role reported in `hello-ok` when available.
- `scopes`: granted scopes reported in `hello-ok` when available.
- `capability`: the surfaced auth capability classification for that target.
</Accordion>
<Accordion title="Common warning codes">
- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes.
- `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot.
- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target.
- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`.
</Accordion>
</AccordionGroup>
- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes.
- `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot.
- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target.
- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`.
#### Remote over SSH (Mac app parity)
The macOS app "Remote over SSH" mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.
The macOS app Remote over SSH mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.
CLI equivalent:
@@ -362,15 +268,13 @@ CLI equivalent:
openclaw gateway probe --ssh user@gateway-host
```
<ParamField path="--ssh <target>" type="string">
`user@host` or `user@host:port` (port defaults to `22`).
</ParamField>
<ParamField path="--ssh-identity <path>" type="string">
Identity file.
</ParamField>
<ParamField path="--ssh-auto" type="boolean">
Pick the first discovered gateway host as SSH target from the resolved discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only hints are ignored.
</ParamField>
Options:
- `--ssh <target>`: `user@host` or `user@host:port` (port defaults to `22`).
- `--ssh-identity <path>`: identity file.
- `--ssh-auto`: pick the first discovered gateway host as SSH target from the resolved
discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only
hints are ignored.
Config (optional, used as defaults):
@@ -386,31 +290,20 @@ openclaw gateway call status
openclaw gateway call logs.tail --params '{"sinceMs": 60000}'
```
<ParamField path="--params <json>" type="string" default="{}">
JSON object string for params.
</ParamField>
<ParamField path="--url <url>" type="string">
Gateway WebSocket URL.
</ParamField>
<ParamField path="--token <token>" type="string">
Gateway token.
</ParamField>
<ParamField path="--password <password>" type="string">
Gateway password.
</ParamField>
<ParamField path="--timeout <ms>" type="number">
Timeout budget.
</ParamField>
<ParamField path="--expect-final" type="boolean">
Mainly for agent-style RPCs that stream intermediate events before a final payload.
</ParamField>
<ParamField path="--json" type="boolean">
Machine-readable JSON output.
</ParamField>
Options:
<Note>
`--params` must be valid JSON.
</Note>
- `--params <json>`: JSON object string for params (default `{}`)
- `--url <url>`
- `--token <token>`
- `--password <password>`
- `--timeout <ms>`
- `--expect-final`
- `--json`
Notes:
- `--params` must be valid JSON.
- `--expect-final` is mainly for agent-style RPCs that stream intermediate events before a final payload.
## Manage the Gateway service
@@ -422,66 +315,28 @@ openclaw gateway restart
openclaw gateway uninstall
```
### Install with a wrapper
Command options:
Use `--wrapper` when the managed service must start through another executable, for example a
secrets manager shim or a run-as helper. The wrapper receives the normal Gateway args and is
responsible for eventually exec'ing `openclaw` or Node with those args.
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- `gateway uninstall|start|stop|restart`: `--json`
```bash
cat > ~/.local/bin/openclaw-doppler <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec doppler run --project my-project --config production -- openclaw "$@"
EOF
chmod +x ~/.local/bin/openclaw-doppler
Notes:
openclaw gateway install --wrapper ~/.local/bin/openclaw-doppler --force
openclaw gateway restart
```
You can also set the wrapper through the environment. `gateway install` validates that the path is
an executable file, writes the wrapper into service `ProgramArguments`, and persists
`OPENCLAW_WRAPPER` in the service environment for later forced reinstalls, updates, and doctor
repairs.
```bash
OPENCLAW_WRAPPER="$HOME/.local/bin/openclaw-doppler" openclaw gateway install --force
openclaw doctor
```
To remove a persisted wrapper, clear `OPENCLAW_WRAPPER` while reinstalling:
```bash
OPENCLAW_WRAPPER= openclaw gateway install --force
openclaw gateway restart
```
<AccordionGroup>
<Accordion title="Command options">
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway uninstall|start|stop|restart`: `--json`
</Accordion>
<Accordion title="Lifecycle behavior">
- Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it.
- Lifecycle commands accept `--json` for scripting.
</Accordion>
<Accordion title="Auth and SecretRefs at install time">
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`.
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
</Accordion>
</AccordionGroup>
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`.
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
- Lifecycle commands accept `--json` for scripting.
## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_openclaw-gw._tcp`).
- Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [Bonjour](/gateway/bonjour).
- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [/gateway/bonjour](/gateway/bonjour)
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
@@ -501,12 +356,10 @@ Wide-Area discovery records include (TXT):
openclaw gateway discover
```
<ParamField path="--timeout <ms>" type="number" default="2000">
Per-command timeout (browse/resolve).
</ParamField>
<ParamField path="--json" type="boolean">
Machine-readable output (also disables styling/spinner).
</ParamField>
Options:
- `--timeout <ms>`: per-command timeout (browse/resolve); default `2000`.
- `--json`: machine-readable output (also disables styling/spinner).
Examples:
@@ -515,11 +368,14 @@ openclaw gateway discover --timeout 4000
openclaw gateway discover --json | jq '.beacons[].wsUrl'
```
<Note>
Notes:
- The CLI scans `local.` plus the configured wide-area domain when one is enabled.
- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only hints such as `lanHost` or `tailnetDns`.
- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when `discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort` stays optional there too.
</Note>
- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only
hints such as `lanHost` or `tailnetDns`.
- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when
`discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort`
stays optional there too.
## Related

View File

@@ -159,7 +159,6 @@ openclaw infer image generate --prompt "cinematic product photo of headphones" -
openclaw infer image generate --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "simple red circle sticker on a transparent background" --json
openclaw infer image generate --prompt "slow image backend" --timeout-ms 180000 --json
openclaw infer image edit --file ./logo.png --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "keep the logo, remove the background" --json
openclaw infer image edit --file ./poster.png --prompt "make this a vertical story ad" --size 2160x3840 --aspect-ratio 9:16 --resolution 4K --json
openclaw infer image describe --file ./photo.jpg --json
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --json
@@ -168,8 +167,6 @@ openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --j
Notes:
- Use `image edit` when starting from existing input files.
- Use `--size`, `--aspect-ratio`, or `--resolution` with `image edit` for
providers/models that support geometry hints on reference-image edits.
- Use `--output-format png --background transparent` with
`--model openai/gpt-image-1.5` for transparent-background OpenAI PNG output;
`--openai-background` remains available as an OpenAI-specific alias. Providers

View File

@@ -5,89 +5,91 @@ read_when:
- Running `openclaw mcp serve`
- Managing OpenClaw-saved MCP server definitions
title: "MCP"
sidebarTitle: "MCP"
---
`openclaw mcp` has two jobs:
- run OpenClaw as an MCP server with `openclaw mcp serve`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `set`, and `unset`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`,
`set`, and `unset`
In other words:
- `serve` is OpenClaw acting as an MCP server
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side registry for other MCP servers its runtimes may consume later
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side
registry for other MCP servers its runtimes may consume later
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness
session itself and route that runtime through ACP.
## OpenClaw as an MCP server
This is the `openclaw mcp serve` path.
### When to use `serve`
## When to use `serve`
Use `openclaw mcp serve` when:
- Codex, Claude Code, or another MCP client should talk directly to OpenClaw-backed channel conversations
- Codex, Claude Code, or another MCP client should talk directly to
OpenClaw-backed channel conversations
- you already have a local or remote OpenClaw Gateway with routed sessions
- you want one MCP server that works across OpenClaw's channel backends instead of running separate per-channel bridges
- you want one MCP server that works across OpenClaw's channel backends instead
of running separate per-channel bridges
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding runtime itself and keep the agent session inside OpenClaw.
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding
runtime itself and keep the agent session inside OpenClaw.
### How it works
## How it works
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that process. While the client keeps the stdio session open, the bridge connects to a local or remote OpenClaw Gateway over WebSocket and exposes routed channel conversations over MCP.
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that
process. While the client keeps the stdio session open, the bridge connects to a
local or remote OpenClaw Gateway over WebSocket and exposes routed channel
conversations over MCP.
<Steps>
<Step title="Client spawns the bridge">
The MCP client spawns `openclaw mcp serve`.
</Step>
<Step title="Bridge connects to Gateway">
The bridge connects to the OpenClaw Gateway over WebSocket.
</Step>
<Step title="Sessions become MCP conversations">
Routed sessions become MCP conversations and transcript/history tools.
</Step>
<Step title="Live events queue">
Live events are queued in memory while the bridge is connected.
</Step>
<Step title="Optional Claude push">
If Claude channel mode is enabled, the same session can also receive Claude-specific push notifications.
</Step>
</Steps>
Lifecycle:
<AccordionGroup>
<Accordion title="Important behavior">
- live queue state starts when the bridge connects
- older transcript history is read with `messages_read`
- Claude push notifications only exist while the MCP session is alive
- when the client disconnects, the bridge exits and the live queue is gone
- one-shot agent entry points such as `openclaw agent` and `openclaw infer model run` retire any bundled MCP runtimes they open when the reply completes, so repeated scripted runs do not accumulate stdio MCP child processes
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn down as a process tree on shutdown, so child subprocesses started by the server do not survive after the parent stdio client exits
- deleting or resetting a session disposes that session's MCP clients through the shared runtime cleanup path, so there are no lingering stdio connections tied to a removed session
</Accordion>
</AccordionGroup>
1. the MCP client spawns `openclaw mcp serve`
2. the bridge connects to Gateway
3. routed sessions become MCP conversations and transcript/history tools
4. live events are queued in memory while the bridge is connected
5. if Claude channel mode is enabled, the same session can also receive
Claude-specific push notifications
### Choose a client mode
Important behavior:
- live queue state starts when the bridge connects
- older transcript history is read with `messages_read`
- Claude push notifications only exist while the MCP session is alive
- when the client disconnects, the bridge exits and the live queue is gone
- one-shot agent entry points such as `openclaw agent` and
`openclaw infer model run` retire any bundled MCP runtimes they open when the
reply completes, so repeated scripted runs do not accumulate stdio MCP child
processes
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn
down as a process tree on shutdown, so child subprocesses started by the
server do not survive after the parent stdio client exits
- deleting or resetting a session disposes that session's MCP clients through
the shared runtime cleanup path, so there are no lingering stdio connections
tied to a removed session
## Choose a client mode
Use the same bridge in two different ways:
<Tabs>
<Tab title="Generic MCP clients">
Standard MCP tools only. Use `conversations_list`, `messages_read`, `events_poll`, `events_wait`, `messages_send`, and the approval tools.
</Tab>
<Tab title="Claude Code">
Standard MCP tools plus the Claude-specific channel adapter. Enable `--claude-channel-mode on` or leave the default `auto`.
</Tab>
</Tabs>
- Generic MCP clients: standard MCP tools only. Use `conversations_list`,
`messages_read`, `events_poll`, `events_wait`, `messages_send`, and the
approval tools.
- Claude Code: standard MCP tools plus the Claude-specific channel adapter.
Enable `--claude-channel-mode on` or leave the default `auto`.
<Note>
Today, `auto` behaves the same as `on`. There is no client capability detection yet.
</Note>
Today, `auto` behaves the same as `on`. There is no client capability detection
yet.
### What `serve` exposes
## What `serve` exposes
The bridge uses existing Gateway session route metadata to expose channel-backed conversations. A conversation appears when OpenClaw already has session state with a known route such as:
The bridge uses existing Gateway session route metadata to expose channel-backed
conversations. A conversation appears when OpenClaw already has session state
with a known route such as:
- `channel`
- recipient or destination metadata
@@ -102,91 +104,101 @@ This gives MCP clients one place to:
- send a reply back through the same route
- see approval requests that arrive while the bridge is connected
### Usage
## Usage
<Tabs>
<Tab title="Local Gateway">
```bash
openclaw mcp serve
```
</Tab>
<Tab title="Remote Gateway (token)">
```bash
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
```
</Tab>
<Tab title="Remote Gateway (password)">
```bash
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
```
</Tab>
<Tab title="Verbose / Claude off">
```bash
openclaw mcp serve --verbose
openclaw mcp serve --claude-channel-mode off
```
</Tab>
</Tabs>
```bash
# Local Gateway
openclaw mcp serve
### Bridge tools
# Remote Gateway
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
# Remote Gateway with password auth
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
# Enable verbose bridge logs
openclaw mcp serve --verbose
# Disable Claude-specific push notifications
openclaw mcp serve --claude-channel-mode off
```
## Bridge tools
The current bridge exposes these MCP tools:
<AccordionGroup>
<Accordion title="conversations_list">
Lists recent session-backed conversations that already have route metadata in Gateway session state.
- `conversations_list`
- `conversation_get`
- `messages_read`
- `attachments_fetch`
- `events_poll`
- `events_wait`
- `messages_send`
- `permissions_list_open`
- `permissions_respond`
Useful filters:
### `conversations_list`
- `limit`
- `search`
- `channel`
- `includeDerivedTitles`
- `includeLastMessage`
Lists recent session-backed conversations that already have route metadata in
Gateway session state.
</Accordion>
<Accordion title="conversation_get">
Returns one conversation by `session_key`.
</Accordion>
<Accordion title="messages_read">
Reads recent transcript messages for one session-backed conversation.
</Accordion>
<Accordion title="attachments_fetch">
Extracts non-text message content blocks from one transcript message. This is a metadata view over transcript content, not a standalone durable attachment blob store.
</Accordion>
<Accordion title="events_poll">
Reads queued live events since a numeric cursor.
</Accordion>
<Accordion title="events_wait">
Long-polls until the next matching queued event arrives or a timeout expires.
Useful filters:
Use this when a generic MCP client needs near-real-time delivery without a Claude-specific push protocol.
- `limit`
- `search`
- `channel`
- `includeDerivedTitles`
- `includeLastMessage`
</Accordion>
<Accordion title="messages_send">
Sends text back through the same route already recorded on the session.
### `conversation_get`
Current behavior:
Returns one conversation by `session_key`.
- requires an existing conversation route
- uses the session's channel, recipient, account id, and thread id
- sends text only
### `messages_read`
</Accordion>
<Accordion title="permissions_list_open">
Lists pending exec/plugin approval requests the bridge has observed since it connected to the Gateway.
</Accordion>
<Accordion title="permissions_respond">
Resolves one pending exec/plugin approval request with:
Reads recent transcript messages for one session-backed conversation.
- `allow-once`
- `allow-always`
- `deny`
### `attachments_fetch`
</Accordion>
</AccordionGroup>
Extracts non-text message content blocks from one transcript message. This is a
metadata view over transcript content, not a standalone durable attachment blob
store.
### Event model
### `events_poll`
Reads queued live events since a numeric cursor.
### `events_wait`
Long-polls until the next matching queued event arrives or a timeout expires.
Use this when a generic MCP client needs near-real-time delivery without a
Claude-specific push protocol.
### `messages_send`
Sends text back through the same route already recorded on the session.
Current behavior:
- requires an existing conversation route
- uses the session's channel, recipient, account id, and thread id
- sends text only
### `permissions_list_open`
Lists pending exec/plugin approval requests the bridge has observed since it
connected to the Gateway.
### `permissions_respond`
Resolves one pending exec/plugin approval request with:
- `allow-once`
- `allow-always`
- `deny`
## Event model
The bridge keeps an in-memory event queue while it is connected.
@@ -199,43 +211,46 @@ Current event types:
- `plugin_approval_resolved`
- `claude_permission_request`
<Warning>
Important limits:
- the queue is live-only; it starts when the MCP bridge starts
- `events_poll` and `events_wait` do not replay older Gateway history by themselves
- `events_poll` and `events_wait` do not replay older Gateway history by
themselves
- durable backlog should be read with `messages_read`
</Warning>
### Claude channel notifications
## Claude channel notifications
The bridge can also expose Claude-specific channel notifications. This is the OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain available, but live inbound messages can also arrive as Claude-specific MCP notifications.
The bridge can also expose Claude-specific channel notifications. This is the
OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain
available, but live inbound messages can also arrive as Claude-specific MCP
notifications.
<Tabs>
<Tab title="off">
`--claude-channel-mode off`: standard MCP tools only.
</Tab>
<Tab title="on">
`--claude-channel-mode on`: enable Claude channel notifications.
</Tab>
<Tab title="auto (default)">
`--claude-channel-mode auto`: current default; same bridge behavior as `on`.
</Tab>
</Tabs>
Flags:
When Claude channel mode is enabled, the server advertises Claude experimental capabilities and can emit:
- `--claude-channel-mode off`: standard MCP tools only
- `--claude-channel-mode on`: enable Claude channel notifications
- `--claude-channel-mode auto`: current default; same bridge behavior as `on`
When Claude channel mode is enabled, the server advertises Claude experimental
capabilities and can emit:
- `notifications/claude/channel`
- `notifications/claude/channel/permission`
Current bridge behavior:
- inbound `user` transcript messages are forwarded as `notifications/claude/channel`
- inbound `user` transcript messages are forwarded as
`notifications/claude/channel`
- Claude permission requests received over MCP are tracked in-memory
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge converts that to `notifications/claude/channel/permission`
- these notifications are live-session only; if the MCP client disconnects, there is no push target
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge
converts that to `notifications/claude/channel/permission`
- these notifications are live-session only; if the MCP client disconnects,
there is no push target
This is intentionally client-specific. Generic MCP clients should rely on the standard polling tools.
This is intentionally client-specific. Generic MCP clients should rely on the
standard polling tools.
### MCP client config
## MCP client config
Example stdio client config:
@@ -257,52 +272,43 @@ Example stdio client config:
}
```
For most generic MCP clients, start with the standard tool surface and ignore Claude mode. Turn Claude mode on only for clients that actually understand the Claude-specific notification methods.
For most generic MCP clients, start with the standard tool surface and ignore
Claude mode. Turn Claude mode on only for clients that actually understand the
Claude-specific notification methods.
### Options
## Options
`openclaw mcp serve` supports:
<ParamField path="--url" type="string">
Gateway WebSocket URL.
</ParamField>
<ParamField path="--token" type="string">
Gateway token.
</ParamField>
<ParamField path="--token-file" type="string">
Read token from file.
</ParamField>
<ParamField path="--password" type="string">
Gateway password.
</ParamField>
<ParamField path="--password-file" type="string">
Read password from file.
</ParamField>
<ParamField path="--claude-channel-mode" type='"auto" | "on" | "off"'>
Claude notification mode.
</ParamField>
<ParamField path="-v, --verbose" type="boolean">
Verbose logs on stderr.
</ParamField>
- `--url <url>`: Gateway WebSocket URL
- `--token <token>`: Gateway token
- `--token-file <path>`: read token from file
- `--password <password>`: Gateway password
- `--password-file <path>`: read password from file
- `--claude-channel-mode <auto|on|off>`: Claude notification mode
- `-v`, `--verbose`: verbose logs on stderr
<Tip>
Prefer `--token-file` or `--password-file` over inline secrets when possible.
</Tip>
### Security and trust boundary
## Security and trust boundary
The bridge does not invent routing. It only exposes conversations that Gateway already knows how to route.
The bridge does not invent routing. It only exposes conversations that Gateway
already knows how to route.
That means:
- sender allowlists, pairing, and channel-level trust still belong to the underlying OpenClaw channel configuration
- sender allowlists, pairing, and channel-level trust still belong to the
underlying OpenClaw channel configuration
- `messages_send` can only reply through an existing stored route
- approval state is live/in-memory only for the current bridge session
- bridge auth should use the same Gateway token or password controls you would trust for any other remote Gateway client
- bridge auth should use the same Gateway token or password controls you would
trust for any other remote Gateway client
If a conversation is missing from `conversations_list`, the usual cause is not MCP configuration. It is missing or incomplete route metadata in the underlying Gateway session.
If a conversation is missing from `conversations_list`, the usual cause is not
MCP configuration. It is missing or incomplete route metadata in the underlying
Gateway session.
### Testing
## Testing
OpenClaw ships a deterministic Docker smoke for this bridge:
@@ -314,60 +320,79 @@ That smoke:
- starts a seeded Gateway container
- starts a second container that spawns `openclaw mcp serve`
- verifies conversation discovery, transcript reads, attachment metadata reads, live event queue behavior, and outbound send routing
- validates Claude-style channel and permission notifications over the real stdio MCP bridge
- verifies conversation discovery, transcript reads, attachment metadata reads,
live event queue behavior, and outbound send routing
- validates Claude-style channel and permission notifications over the real
stdio MCP bridge
This is the fastest way to prove the bridge works without wiring a real Telegram, Discord, or iMessage account into the test run.
This is the fastest way to prove the bridge works without wiring a real
Telegram, Discord, or iMessage account into the test run.
For broader testing context, see [Testing](/help/testing).
### Troubleshooting
## Troubleshooting
<AccordionGroup>
<Accordion title="No conversations returned">
Usually means the Gateway session is not already routable. Confirm that the underlying session has stored channel/provider, recipient, and optional account/thread route metadata.
</Accordion>
<Accordion title="events_poll or events_wait misses older messages">
Expected. The live queue starts when the bridge connects. Read older transcript history with `messages_read`.
</Accordion>
<Accordion title="Claude notifications do not show up">
Check all of these:
### No conversations returned
- the client kept the stdio MCP session open
- `--claude-channel-mode` is `on` or `auto`
- the client actually understands the Claude-specific notification methods
- the inbound message happened after the bridge connected
Usually means the Gateway session is not already routable. Confirm that the
underlying session has stored channel/provider, recipient, and optional
account/thread route metadata.
</Accordion>
<Accordion title="Approvals are missing">
`permissions_list_open` only shows approval requests observed while the bridge was connected. It is not a durable approval history API.
</Accordion>
</AccordionGroup>
### `events_poll` or `events_wait` misses older messages
Expected. The live queue starts when the bridge connects. Read older transcript
history with `messages_read`.
### Claude notifications do not show up
Check all of these:
- the client kept the stdio MCP session open
- `--claude-channel-mode` is `on` or `auto`
- the client actually understands the Claude-specific notification methods
- the inbound message happened after the bridge connected
### Approvals are missing
`permissions_list_open` only shows approval requests observed while the bridge
was connected. It is not a durable approval history API.
## OpenClaw as an MCP client registry
This is the `openclaw mcp list`, `show`, `set`, and `unset` path.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP
server definitions under `mcp.servers` in OpenClaw config.
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded Pi and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.
Those saved definitions are for runtimes that OpenClaw launches or configures
later, such as embedded Pi and other runtime adapters. OpenClaw stores the
definitions centrally so those runtimes do not need to keep their own duplicate
MCP server lists.
<AccordionGroup>
<Accordion title="Important behavior">
- these commands only read or write OpenClaw config
- they do not connect to the target MCP server
- they do not validate whether the command, URL, or remote transport is reachable right now
- runtime adapters decide which transport shapes they actually support at execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
</Accordion>
</AccordionGroup>
Important behavior:
Runtime adapters may normalize this shared registry into the shape their downstream client expects. For example, embedded Pi consumes OpenClaw `transport` values directly, while Claude Code and Gemini receive CLI-native `type` values such as `http`, `sse`, or `stdio`.
- these commands only read or write OpenClaw config
- they do not connect to the target MCP server
- they do not validate whether the command, URL, or remote transport is
reachable right now
- runtime adapters decide which transport shapes they actually support at
execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
disables them explicitly
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs`
milliseconds of idle time (default 10 minutes; set `0` to disable) and
one-shot embedded runs clean them up at run end
### Saved MCP server definitions
Runtime adapters may normalize this shared registry into the shape their
downstream client expects. For example, embedded Pi consumes OpenClaw
`transport` values directly, while Claude Code and Gemini receive CLI-native
`type` values such as `http`, `sse`, or `stdio`.
OpenClaw also stores a lightweight MCP server registry in config for surfaces that want OpenClaw-managed MCP definitions.
## Saved MCP server definitions
OpenClaw also stores a lightweight MCP server registry in config for surfaces
that want OpenClaw-managed MCP definitions.
Commands:
@@ -422,13 +447,11 @@ Launches a local child process and communicates over stdin/stdout.
| `env` | Extra environment variables |
| `cwd` / `workingDirectory` | Working directory for the process |
<Warning>
**Stdio env safety filter**
#### Stdio env safety filter
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, or enable a debugger against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
</Warning>
### SSE / HTTP transport
@@ -457,7 +480,8 @@ Example:
}
```
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and status output.
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and
status output.
### Streamable HTTP transport
@@ -489,9 +513,8 @@ Example:
}
```
<Note>
These commands manage saved config only. They do not start the channel bridge, open a live MCP client session, or prove the target server is reachable.
</Note>
These commands manage saved config only. They do not start the channel bridge,
open a live MCP client session, or prove the target server is reachable.
## Current limits
@@ -503,7 +526,8 @@ Current limits:
- no generic push protocol beyond the Claude-specific adapter
- no message edit or react tools yet
- HTTP/SSE/streamable-http transport connects to a single remote server; no multiplexed upstream yet
- `permissions_list_open` only includes approvals observed while the bridge is connected
- `permissions_list_open` only includes approvals observed while the bridge is
connected
## Related

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