Compare commits

...

63 Commits

Author SHA1 Message Date
Peter Steinberger
a410f05a09 chore(release): bump 2026.4.25 beta 10 2026-04-26 20:48:56 +01:00
Vincent Koc
55d1a2e0e0 fix(logging): redact persisted transcript text
(cherry picked from commit 406ae72fd2)
2026-04-26 20:48:48 +01:00
Peter Steinberger
c8972376cb docs(changelog): remove codex credits 2026-04-26 20:20:03 +01:00
Peter Steinberger
377041cd75 chore(release): bump 2026.4.25 beta 9 2026-04-26 20:06:06 +01:00
Peter Steinberger
d32a7916bd docs(changelog): note beta 9 backports 2026-04-26 20:05:50 +01:00
Vincent Koc
2c625f9368 fix: repair skills and memory watcher refresh paths
(cherry picked from commit e53c068d78)
2026-04-26 20:05:32 +01:00
Peter Steinberger
5ea41fe40c test(gateway): classify stream fallback as empty live response
(cherry picked from commit 4e181d30fa)
2026-04-26 20:05:23 +01:00
Peter Steinberger
cec1d46b30 test(gateway): harden acp bind docker smoke
(cherry picked from commit e60cc50dff)
2026-04-26 20:05:23 +01:00
Peter Steinberger
a8ba87ee90 fix(agents): keep responses web search reasoning compatible
(cherry picked from commit f2dab9b334)
2026-04-26 20:05:10 +01:00
Peter Steinberger
3f821a8888 fix(agents): honor bundle mcp tool allowlist
(cherry picked from commit fc6cfbd418)
2026-04-26 20:05:10 +01:00
Vincent Koc
1a3c480155 fix: shortcut live session model redirects during fallback
(cherry picked from commit 480a3f66c9)
2026-04-26 20:05:10 +01:00
Vincent Koc
683437fe61 fix(discord): escalate repeated health-monitor restarts
(cherry picked from commit b4cdd55f62)
2026-04-26 20:04:53 +01:00
Peter Steinberger
095e1a90f5 docs(release): allow retagging unpublished betas 2026-04-26 19:39:52 +01:00
Peter Steinberger
227a07558b docs(changelog): place auto-reply backport in 2026.4.25 2026-04-26 19:33:19 +01:00
Vincent Koc
773e302179 fix(auto-reply): poison inbound dedupe after partial turn failure
* fix(auto-reply): poison inbound dedupe after replay-unsafe failures

* fix(clownfish): address review for ghcrawl-165980-agentic-merge (1)
2026-04-26 19:10:34 +01:00
Peter Steinberger
ec71b01f71 chore(release): bump 2026.4.25 beta 8 2026-04-26 18:56:52 +01:00
Peter Steinberger
ca9fb36d53 docs(changelog): place WhatsApp backport in 2026.4.25 2026-04-26 18:50:48 +01:00
Vincent Koc
1f194f1d55 fix(whatsapp): stop reconnecting quiet sockets
Fixes #70678.\n\nKeeps quiet but healthy WhatsApp linked-device sessions connected by tracking WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Also cleans up transport activity listeners on failed connection-open paths.\n\nCarries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.\n\nValidation:\n- pnpm test:serial extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts extensions/whatsapp/src/connection-controller.test.ts\n- pnpm check:changed\n- codex review --base origin/main
2026-04-26 18:37:01 +01:00
Peter Steinberger
a188d486dd chore(release): bump 2026.4.25 beta 7 2026-04-26 18:17:26 +01:00
Peter Steinberger
a4266be808 test(release): stabilize release validation waits 2026-04-26 18:11:12 +01:00
Peter Steinberger
90c40e9f90 chore(release): bump 2026.4.25 beta 6 2026-04-26 17:20:37 +01:00
Peter Steinberger
b77514b6d9 fix: avoid PowerShell error variable collision 2026-04-26 17:19:53 +01:00
Peter Steinberger
a813219b6b chore(release): bump 2026.4.25 beta 5 2026-04-26 16:48:34 +01:00
Peter Steinberger
4ac1406644 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 16:24:13 +01:00
Peter Steinberger
4d0e1470df fix(release): stabilize beta validation lanes 2026-04-26 16:21:56 +01:00
Peter Steinberger
6ecae22943 chore(release): bump 2026.4.25 beta 4 2026-04-26 14:24:00 +01:00
Peter Steinberger
2c5ac5c0e2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 14:17:08 +01:00
Peter Steinberger
8c309aa3de chore(release): bump 2026.4.25 beta 3 2026-04-26 13:54:23 +01:00
Peter Steinberger
3c89b16fb0 test(release): wait longer for dashboard smoke 2026-04-26 13:53:50 +01:00
Peter Steinberger
ef447c43c7 test(qa): allow slower gateway rpc startup retries 2026-04-26 13:51:24 +01:00
Peter Steinberger
ddb66a71af Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 13:49:27 +01:00
Peter Steinberger
9b1583112a test(extensions): restore transformed dynamic imports 2026-04-26 13:15:46 +01:00
Peter Steinberger
865fde8f72 chore(release): bump 2026.4.25 beta 2 2026-04-26 13:00:00 +01:00
Peter Steinberger
ccc8d71461 fix(cli): keep channel add plugin install noninteractive 2026-04-26 12:58:33 +01:00
Peter Steinberger
a947464403 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:46:42 +01:00
Peter Steinberger
63803d78f4 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:15:22 +01:00
Peter Steinberger
dcad0256b2 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 12:08:12 +01:00
Peter Steinberger
12b1a63b84 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:07:37 +01:00
Peter Steinberger
6ea3f30b9b Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:03:01 +01:00
Peter Steinberger
660dcf2c94 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 11:52:40 +01:00
Peter Steinberger
26ab654da2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:52:07 +01:00
Peter Steinberger
5bc728d480 docs(release): refine beta validation guidance 2026-04-26 11:51:06 +01:00
Peter Steinberger
3779853ef9 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:45:31 +01:00
Peter Steinberger
b4ff947206 fix(ui): remove ineffective dynamic imports 2026-04-26 11:45:22 +01:00
Peter Steinberger
1e464867e7 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:41:22 +01:00
Peter Steinberger
ea9da71f03 test: type setup provider mocks 2026-04-26 11:41:08 +01:00
Peter Steinberger
1dbc246e29 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 11:39:46 +01:00
Peter Steinberger
41c7256420 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	.agents/skills/openclaw-release-maintainer/SKILL.md
#	CHANGELOG.md
#	package.json
#	src/config/schema.base.generated.ts
2026-04-26 10:00:50 +01:00
Peter Steinberger
b7733c48c0 docs(release): codify beta train backport scan 2026-04-26 09:58:34 +01:00
Peter Steinberger
50565b05aa docs(changelog): add 2026.4.25 release highlights 2026-04-26 09:40:56 +01:00
Vincent Koc
2e10d87919 docs(changelog): flatten 27 multi-line bullets into single lines per AGENTS.md rule
(cherry picked from commit eb6b35671a)
2026-04-26 09:40:42 +01:00
Peter Steinberger
0ca3fae91a fix: hide raw agent failures in group chats
(cherry picked from commit 1969452c3f)
2026-04-26 09:40:40 +01:00
Peter Steinberger
308ba59151 test: update npm telegram workflow expectations
(cherry picked from commit 4ad8b613c9)
2026-04-26 09:40:38 +01:00
Vincent Koc
6ca5907692 fix(runtime): harden dependency install surfaces (#71997)
* fix(runtime): harden dependency surfaces

* fix(runtime): harden dependency install surfaces

* fix(runtime): address dependency surface review

* fix(runtime): address dependency surface review

* fix(channels): avoid read-only plugin loader cycle

* fix(channels): allow optional read-only loader workspace

* test(commands): refresh current main checks

* test(commands): keep provider metadata mock unique

* test(commands): keep doctor security read-only mock unique

(cherry picked from commit abd5ec98ab)
2026-04-26 09:40:19 +01:00
Peter Steinberger
b9758bf44a docs(plugin-sdk): refresh beta api baseline after main sync 2026-04-26 09:23:31 +01:00
Peter Steinberger
b923421129 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	src/plugin-sdk/command-auth.ts
#	src/plugins/command-registration.ts
#	src/plugins/command-registry-state.ts
#	src/plugins/command-specs.ts
#	src/plugins/commands.ts
2026-04-26 09:18:34 +01:00
Peter Steinberger
c6276d6b19 docs(plugin-sdk): refresh api baseline 2026-04-26 08:58:39 +01:00
Peter Steinberger
399b41bbdb docs(config): refresh channel config baseline 2026-04-26 08:57:20 +01:00
Peter Steinberger
1ce1713139 chore(config): refresh bundled channel metadata 2026-04-26 08:56:20 +01:00
Peter Steinberger
1768995c37 chore(release): sync beta config schema 2026-04-26 08:54:43 +01:00
Peter Steinberger
ced0e96cf2 fix: break plugin command spec import cycle 2026-04-26 08:46:02 +01:00
Peter Steinberger
dd13141903 fix: satisfy traceparent header lint 2026-04-26 08:43:26 +01:00
Peter Steinberger
072a5ae4b0 chore(release): prepare 2026.4.25 beta 1 2026-04-26 08:41:57 +01:00
53 changed files with 1644 additions and 198 deletions

View File

@@ -41,9 +41,16 @@ Use this skill for release and publish-time workflow. Keep ordinary development
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
- Do not delete or rewrite any beta tag after the matching npm package has been
published, or after a GitHub release/prerelease was created from that tag. If
an npm-published beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- Beta-only Git tags that were pushed for preflight but never published to npm
may be moved or replaced when the operator explicitly approves it. Before
retagging, verify `npm view openclaw@YYYY.M.D-beta.N version` is unpublished
and no GitHub release/prerelease exists for the tag; after retagging, push the
updated tag intentionally and rerun npm preflight because older preflight
artifacts are tied to the previous tag SHA.
- 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

View File

@@ -6,22 +6,25 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.
- 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"`.
- 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.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- 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.
- 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.
- 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.
- 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.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- 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.
## 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.
- 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.
- 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.
@@ -118,13 +121,37 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- Agents/OpenAI: keep Responses web search compatible with minimal thinking by raising `web_search` requests to the lowest supported reasoning effort instead of sending a rejected minimal payload.
- Agents/tools: honor the `bundle-mcp` allowlist token when deciding whether bundled MCP tools are available, so restricted tool policies can still enable bundled MCP without exposing unrelated tools.
- 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.
- 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.
- 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.
- 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.
- 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"`.
- 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.
- 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.
- 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.
- 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.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- 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.
- 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.
- 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.
- 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.
- 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.
@@ -226,7 +253,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
@@ -4584,7 +4611,7 @@ Docs: https://docs.openclaw.ai
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881)
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025).
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007).
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool .. not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.

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

@@ -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

@@ -1,4 +1,4 @@
7fa6e35bb9f9d3096d6281f141488be0dcfe15de40dc4f5c0305eb1ff2bc60b6 config-baseline.json
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
a62ead999508b18d9ea3e1c129e3cdd44244af0ff0e6f81653dfced9aa52019a config-baseline.json
3245c9a013c55ee8a24db52d5e88c42bc86e26f822d4a144fc7f37fc71e05fa8 config-baseline.core.json
080c0a4f2d4175d6d7ab1e38f76b21de32669055c518d75c96e784865d89bf25 config-baseline.channel.json
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json

View File

@@ -146,6 +146,7 @@ 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).
@@ -510,6 +511,10 @@ 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

View File

@@ -859,6 +859,7 @@ Notes:
- Set `logging.file` for a stable path.
- `consoleLevel` bumps to `debug` when `--verbose`.
- `maxFileBytes`: maximum active log file size in bytes before rotation (positive integer; default: `104857600` = 100 MB). OpenClaw keeps up to five numbered archives beside the active file.
- `redactSensitive` / `redactPatterns`: best-effort masking for console output, file logs, OTLP log records, and persisted session transcript text.
---

View File

@@ -52,10 +52,12 @@ You can tune console verbosity independently via:
- `logging.consoleLevel` (default `info`)
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
## Tool summary redaction
## Redaction
Verbose tool summaries (e.g. `🛠️ Exec: ...`) can mask sensitive tokens before they hit the
console stream. This is **tools-only** and does not alter file logs.
OpenClaw can mask sensitive tokens before log or transcript output leaves the
process. The same redaction policy is applied at console, file-log, OTLP
log-record, and session transcript text sinks, so matching secret values are
masked before JSONL lines or messages are written to disk.
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: array of regex strings (overrides defaults)

View File

@@ -999,7 +999,7 @@ Logs and transcripts can leak sensitive info even when access controls are corre
Recommendations:
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
- Keep log and transcript redaction on (`logging.redactSensitive: "tools"`; default).
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
- When sharing diagnostics, prefer `openclaw status --all` (pasteable, secrets redacted) over raw logs.
- Prune old session transcripts and log files if you dont need long retention.

View File

@@ -227,10 +227,12 @@ Notes:
- `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.2`
- `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL=opencode/kimi-k2.6`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1`
- `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.2`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
- When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent.
- Bound-session cron MCP creation is best-effort by default because external ACP harnesses can cancel MCP calls after the bind/image proof has passed; set `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1` to make that post-bind cron probe strict.
Example:

View File

@@ -167,14 +167,16 @@ file log levels.
### Redaction
Tool summaries can redact sensitive tokens before they hit the console:
OpenClaw can redact sensitive tokens before they hit console output, file logs,
OTLP log records, or persisted session transcript text:
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: list of regex strings to override the default set
Redaction applies at the logging sinks for **console output**, **stderr-routed
console diagnostics**, and **file logs**. File logs stay JSONL, but matching
secret values are masked before the line is written to disk.
File logs and session transcripts stay JSONL, but matching secret values are
masked before the line or message is written to disk. Redaction is best-effort:
it applies to text-bearing message content and log strings, not every
identifier or binary payload field.
## Diagnostics and OpenTelemetry

View File

@@ -5,11 +5,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import chokidar, { FSWatcher } from "chokidar";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createSubsystemLogger,
onSessionTranscriptUpdate,
@@ -105,6 +101,9 @@ function shouldIgnoreMemoryWatchPath(
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const extension = normalizeLowercaseStringOrEmpty(path.extname(normalized));
if (extension.length === 0 || extension === ".md") {
return false;
@@ -383,16 +382,7 @@ export abstract class MemoryManagerSyncOps {
continue;
}
if (stat.isDirectory()) {
watchPaths.add(path.join(entry, "**", "*.md"));
if (this.settings.multimodal.enabled) {
for (const modality of this.settings.multimodal.modalities) {
for (const extension of getMemoryMultimodalExtensions(modality)) {
watchPaths.add(
path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension)),
);
}
}
}
watchPaths.add(entry);
continue;
}
if (
@@ -422,6 +412,7 @@ export abstract class MemoryManagerSyncOps {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("unlinkDir", markDirty);
}
protected ensureSessionListener() {

View File

@@ -11,12 +11,35 @@ import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"
type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean;
const { watchMock } = vi.hoisted(() => ({
watchMock: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
}));
const { createdWatchers, watchMock } = vi.hoisted(() => {
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir";
type WatchCallback = () => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent) => {
for (const callback of handlers.get(event) ?? []) {
callback();
}
},
};
return watcher;
}
const watchers: Array<ReturnType<typeof createMockWatcher>> = [];
return {
createdWatchers: watchers,
watchMock: vi.fn(() => {
const watcher = createMockWatcher();
watchers.push(watcher);
return watcher;
}),
};
});
vi.mock("chokidar", () => ({
default: { watch: watchMock },
@@ -69,7 +92,9 @@ describe("memory watcher config", () => {
});
afterEach(async () => {
vi.useRealTimers();
watchMock.mockClear();
createdWatchers.length = 0;
if (manager) {
await manager.close();
manager = null;
@@ -140,9 +165,10 @@ describe("memory watcher config", () => {
expect.arrayContaining([
path.join(workspaceDir, "MEMORY.md"),
path.join(workspaceDir, "memory"),
path.join(extraDir, "**", "*.md"),
extraDir,
]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
expect(options.ignoreInitial).toBe(true);
expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 });
@@ -152,15 +178,19 @@ describe("memory watcher config", () => {
true,
);
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), undefined)).toBe(
false,
);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"), {})).toBe(false);
expect(
ignored?.(path.join(workspaceDir, "memory", "project"), { isDirectory: () => true }),
).toBe(false);
});
it("watches multimodal extensions with case-insensitive globs", async () => {
it("watches multimodal extra directories with filtered extensions", async () => {
await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" });
const cfg = createWatcherConfig({
provider: "gemini",
@@ -177,16 +207,40 @@ describe("memory watcher config", () => {
Record<string, unknown>,
];
expect(watchedPaths).toEqual(
expect.arrayContaining([
path.join(extraDir, "**", "*.[pP][nN][gG]"),
path.join(extraDir, "**", "*.[wW][aA][vV]"),
]),
expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
const ignored = options.ignored as WatchIgnoredFn | undefined;
expect(ignored).toBeTypeOf("function");
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"))).toBe(true);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"), {})).toBe(true);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"schedules watch sync on %s",
async (event) => {
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
const cfg = createWatcherConfig();
await expectWatcherManager(cfg);
vi.useFakeTimers();
const syncSpy = vi
.spyOn(
manager as unknown as {
sync: (params?: { reason?: string }) => Promise<void>;
},
"sync",
)
.mockResolvedValue(undefined);
createdWatchers[0]?.emit(event);
await vi.advanceTimersByTimeAsync(25);
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
},
);
});

View File

@@ -1,4 +1,5 @@
import "./test-helpers.js";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -42,25 +43,57 @@ type WebAutoReplyMonitorHarness = {
controller: AbortController;
run: Promise<unknown>;
};
type MockSessionSocket = {
ev: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn> };
ws: EventEmitter & { close: ReturnType<typeof vi.fn> };
user: { id: string };
};
export const TEST_NET_IP = "93.184.216.34";
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
function getSessionSockets(): MockSessionSocket[] {
const store = globalThis as Record<PropertyKey, unknown>;
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
}
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
}
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn(async () => ({
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws: { close: vi.fn() },
user: { id: "123@s.whatsapp.net" },
})),
createWaSocket: vi.fn(async () => {
const ws = new EventEmitter() as MockSessionSocket["ws"];
ws.close = vi.fn();
const sock: MockSessionSocket = {
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws,
user: { id: "123@s.whatsapp.net" },
};
getSessionSockets().push(sock);
return sock;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};
});
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
const last = getSessionSockets().at(-1);
if (!last) {
throw new Error("No WhatsApp Web auto-reply test socket created");
}
return last;
}
export function resetWebAutoReplySessionSockets() {
getSessionSockets().length = 0;
}
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
appendCronStyleCurrentTimeLine: (text: string) => text,
@@ -166,6 +199,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
beforeEach(async () => {
vi.clearAllMocks();
resetWebAutoReplySessionSockets();
_resetBaileysMocks();
_resetLoadConfigMock();
if (opts?.pinDns) {

View File

@@ -12,6 +12,7 @@ import {
createMockWebListener,
createScriptedWebListenerFactory,
createWebListenerFactoryCapture,
getLastWebAutoReplySessionSocket,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
makeSessionStore,
@@ -255,6 +256,92 @@ describe("web auto-reply connection", () => {
}
});
it("keeps quiet linked-device sessions open when transport frames keep arriving", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
expect(scripted.getListenerCount()).toBe(1);
controller.abort();
scripted.resolveClose(0, { status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("does not let transport frames mask application silence forever", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
for (let elapsedMs = 0; elapsedMs < 140; elapsedMs += 20) {
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
}
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
},
{ timeout: 250, interval: 2 },
);
controller.abort();
scripted.resolveClose(scripted.getListenerCount() - 1, {
status: 499,
isLoggedOut: false,
error: "aborted",
});
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("gives a reconnected listener a fresh watchdog window", async () => {
vi.useFakeTimers();
try {

View File

@@ -280,6 +280,7 @@ export async function monitorWebChannel(
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
lastTransportActivityAt: snapshot.lastTransportActivityAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
@@ -297,20 +298,28 @@ export async function monitorWebChannel(
}
},
onWatchdogTimeout: (snapshot) => {
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
const now = Date.now();
const transportSilentMs = now - snapshot.lastTransportActivityAt;
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
const watchdogReason =
transportSilentMs > messageTimeoutMs ? "transport-inactive" : "app-silent";
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
minutesSinceLastMessage,
watchdogReason,
minutesSinceTransportActivity,
minutesSinceAppActivity,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
messagesHandled: snapshot.handledMessages,
},
"Message timeout detected - forcing reconnect",
"WhatsApp watchdog timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
);
},
});

View File

@@ -40,8 +40,10 @@ export type WhatsAppLiveConnection = {
heartbeat: TimerHandle | null;
watchdogTimer: TimerHandle | null;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
unregisterUnhandled: (() => void) | null;
unregisterTransportActivity: (() => void) | null;
backgroundTasks: Set<Promise<unknown>>;
closePromise: Promise<WebListenerCloseReason>;
resolveClose: (reason: WebListenerCloseReason) => void;
@@ -51,6 +53,7 @@ export type WhatsAppConnectionSnapshot = {
connectionId: string;
startedAt: number;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
reconnectAttempts: number;
uptimeMs: number;
@@ -83,6 +86,12 @@ function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
};
function createLiveConnection(params: {
connectionId: string;
sock: WASocket;
@@ -108,8 +117,10 @@ function createLiveConnection(params: {
heartbeat: null,
watchdogTimer: null,
lastInboundAt: null,
lastTransportActivityAt: Date.now(),
handledMessages: 0,
unregisterUnhandled: null,
unregisterTransportActivity: null,
backgroundTasks: new Set<Promise<unknown>>(),
closePromise,
resolveClose: resolveClosePromise,
@@ -232,6 +243,7 @@ export class WhatsAppConnectionController {
private readonly heartbeatSeconds: number;
private readonly keepAlive: boolean;
private readonly messageTimeoutMs: number;
private readonly appSilenceTimeoutMs: number;
private readonly watchdogCheckMs: number;
private readonly verbose: boolean;
private readonly abortSignal?: AbortSignal;
@@ -262,6 +274,7 @@ export class WhatsAppConnectionController {
this.keepAlive = params.keepAlive;
this.heartbeatSeconds = params.heartbeatSeconds;
this.messageTimeoutMs = params.messageTimeoutMs;
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
this.watchdogCheckMs = params.watchdogCheckMs;
this.reconnectPolicy = params.reconnectPolicy;
this.abortSignal = params.abortSignal;
@@ -311,6 +324,14 @@ export class WhatsAppConnectionController {
}
this.current.handledMessages += 1;
this.current.lastInboundAt = timestamp;
this.current.lastTransportActivityAt = timestamp;
}
noteTransportActivity(timestamp = Date.now()): void {
if (!this.current) {
return;
}
this.current.lastTransportActivityAt = timestamp;
}
getCurrentSnapshot(
@@ -323,6 +344,7 @@ export class WhatsAppConnectionController {
connectionId: connection.connectionId,
startedAt: connection.startedAt,
lastInboundAt: connection.lastInboundAt,
lastTransportActivityAt: connection.lastTransportActivityAt,
handledMessages: connection.handledMessages,
reconnectAttempts: this.reconnectAttempts,
uptimeMs: Date.now() - connection.startedAt,
@@ -369,6 +391,7 @@ export class WhatsAppConnectionController {
const listener = await params.createListener({ sock, connection });
connection.listener = listener;
this.current = connection;
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
registerWhatsAppConnectionController(this.accountId, this);
this.startTimers(connection, {
onHeartbeat: params.onHeartbeat,
@@ -383,6 +406,7 @@ export class WhatsAppConnectionController {
if (connection?.unregisterUnhandled) {
connection.unregisterUnhandled();
}
connection?.unregisterTransportActivity?.();
throw err;
}
}
@@ -515,6 +539,7 @@ export class WhatsAppConnectionController {
this.socketRef.current = null;
}
connection.unregisterUnhandled?.();
connection.unregisterTransportActivity?.();
if (connection.heartbeat) {
clearInterval(connection.heartbeat);
}
@@ -563,9 +588,14 @@ export class WhatsAppConnectionController {
}, this.heartbeatSeconds * 1000);
connection.watchdogTimer = setInterval(() => {
const baselineAt = connection.lastInboundAt ?? connection.startedAt;
const staleForMs = Date.now() - baselineAt;
if (staleForMs <= this.messageTimeoutMs) {
const now = Date.now();
const transportStaleForMs = now - connection.lastTransportActivityAt;
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
const appSilentForMs = now - appBaselineAt;
if (
transportStaleForMs <= this.messageTimeoutMs &&
appSilentForMs <= this.appSilenceTimeoutMs
) {
return;
}
const snapshot = this.getCurrentSnapshot(connection);
@@ -581,6 +611,24 @@ export class WhatsAppConnectionController {
}, this.watchdogCheckMs);
}
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
const ws = sock.ws as SocketActivityEmitter | undefined;
if (!ws || typeof ws.on !== "function") {
return null;
}
const noteActivity = () => this.noteTransportActivity();
ws.on("frame", noteActivity);
return () => {
if (typeof ws.off === "function") {
ws.off("frame", noteActivity);
return;
}
ws.removeListener?.("frame", noteActivity);
};
}
private stopDisconnectRetries(): void {
if (!this.disconnectRetryController.signal.aborted) {
this.disconnectRetryController.abort();

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.26",
"version": "2026.4.25-beta.10",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -18,7 +18,7 @@ $ErrorActionPreference = "Stop"
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
$WARN = "`e[38;2;255;176;32m" # amber
$ERROR = "`e[38;2;230;57;70m" # coral-mid
$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid
$MUTED = "`e[38;2;90;100;128m" # text-muted
$NC = "`e[0m" # No Color
@@ -27,7 +27,7 @@ function Write-Host {
$msg = switch ($Level) {
"success" { "$SUCCESS$NC $Message" }
"warn" { "$WARN!$NC $Message" }
"error" { "$ERROR$NC $Message" }
"error" { "$ERROR_COLOR$NC $Message" }
default { "$MUTED·$NC $Message" }
}
Microsoft.PowerShell.Utility\Write-Host $msg

View File

@@ -148,6 +148,7 @@ exec "\$script_dir/claude-real" "\$@"
WRAP
chmod +x "$NPM_CONFIG_PREFIX/bin/claude"
fi
export CLAUDE_CODE_EXECUTABLE="$NPM_CONFIG_PREFIX/bin/claude"
claude auth status || true
;;
codex)
@@ -162,8 +163,8 @@ WRAP
fi
droid --version
if [ -z "${FACTORY_API_KEY:-}" ]; then
echo "Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 1
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 0
fi
;;
gemini)
@@ -262,6 +263,16 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
DOCKER_AUTH_PRESTAGED=1
fi
if [[ "$ACP_AGENT" == "droid" && -z "${FACTORY_API_KEY:-}" ]]; then
echo "==> Run ACP bind live test in Docker"
echo "==> Agent: $ACP_AGENT"
echo "==> Profile file: $PROFILE_STATUS"
echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}"
echo "==> Auth files: ${AUTH_FILES_CSV:-none}"
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
continue
fi
EXTERNAL_AUTH_MOUNTS=()
if ((${#AUTH_DIRS[@]} > 0)); then
for auth_dir in "${AUTH_DIRS[@]}"; do

View File

@@ -707,6 +707,55 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("jumps directly to a later live-session model switch candidate (#57471)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [
"anthropic/claude-haiku-3-5",
"anthropic/claude-sonnet-4-6",
"openrouter/deepseek-chat",
],
},
},
},
});
const switchError = new LiveSessionModelSwitchError({
provider: "anthropic",
model: "claude-sonnet-4-6",
});
const run = vi.fn(async (provider: string, model: string) => {
if (provider === "openai" && model === "gpt-4.1-mini") {
throw switchError;
}
if (provider === "anthropic" && model === "claude-sonnet-4-6") {
return "ok";
}
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
});
const onError = vi.fn();
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
onError,
});
expect(result.result).toBe("ok");
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet-4-6");
expect(result.attempts).toEqual([]);
expect(onError).not.toHaveBeenCalled();
expect(run.mock.calls).toEqual([
["openai", "gpt-4.1-mini"],
["anthropic", "claude-sonnet-4-6"],
]);
});
it("falls back on auth errors", async () => {
await expectFallsBackToHaiku({
provider: "openai",

View File

@@ -326,6 +326,18 @@ function recordFailedCandidateAttempt(params: {
});
}
function findLaterLiveSessionModelSwitchCandidateIndex(params: {
error: LiveSessionModelSwitchError;
candidates: ModelCandidate[];
currentIndex: number;
}): number | null {
const targetKey = modelKey(params.error.provider, params.error.model);
const targetIndex = params.candidates.findIndex(
(candidate) => modelKey(candidate.provider, candidate.model) === targetKey,
);
return targetIndex > params.currentIndex ? targetIndex : null;
}
function throwFallbackFailureSummary(params: {
attempts: FallbackAttempt[];
candidates: ModelCandidate[];
@@ -924,6 +936,16 @@ export async function runWithModelFallback<T>(params: {
// instead of re-throwing and triggering infinite retry loops in the
// outer runner. (#58466)
if (err instanceof LiveSessionModelSwitchError) {
const liveSwitchTargetIndex = findLaterLiveSessionModelSwitchCandidateIndex({
error: err,
candidates,
currentIndex: i,
});
if (liveSwitchTargetIndex !== null) {
i = liveSwitchTargetIndex - 1;
continue;
}
const switchMsg = err.message;
const switchNormalized = new FailoverError(switchMsg, {
reason: "overloaded",

View File

@@ -700,6 +700,36 @@ describe("applyExtraParamsToAgent", () => {
});
});
it("keeps OpenAI Responses web_search compatible when thinking is minimal", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
reasoning: true,
} as Model<"openai-responses">,
payload: {
model: "gpt-5",
input: [],
tools: [
{
type: "function",
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
},
],
reasoning: { effort: "low", summary: "auto" },
},
thinkingLevel: "minimal",
});
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it("strips disabled reasoning payloads for proxied OpenAI responses routes", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
@@ -35,4 +36,46 @@ describe("guardSessionManager integration", () => {
"assistant",
]);
});
it("redacts configured text patterns before persisting transcript messages", () => {
const cfg = {
logging: {
redactSensitive: "tools",
redactPatterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
},
} satisfies OpenClawConfig;
const sm = guardSessionManager(SessionManager.inMemory(), { config: cfg });
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
appendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "the email is peter@dc.io", thinkingSignature: "sig" },
{ type: "text", text: "contact peter@dc.io" },
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp/peter@dc.io" } },
],
stopReason: "toolUse",
} as AgentMessage);
appendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "peter@dc.io\n" }],
isError: false,
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
const serialized = JSON.stringify(messages);
expect(serialized).not.toContain("the email is peter@dc.io");
expect(serialized).not.toContain("contact peter@dc.io");
expect(serialized).not.toContain("peter@dc.io\\n");
expect(serialized).toContain('"thinking":"the email is peter@d***.io"');
expect(serialized).toContain('"text":"contact peter@d***.io"');
expect(serialized).toContain('"text":"peter@d***.io\\n"');
expect(serialized).toContain('"/tmp/peter@dc.io"');
});
});

View File

@@ -159,6 +159,32 @@ describe("createOpenAIThinkingLevelWrapper", () => {
}
});
it("raises minimal reasoning for web_search on loopback Responses routes", () => {
const payloads: Array<Record<string, unknown>> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
reasoning: { effort: "minimal", summary: "auto" },
tools: [{ type: "function", name: "web_search" }],
};
options?.onPayload?.(payload, _model);
payloads.push(structuredClone(payload));
return createAssistantMessageEventStream();
};
const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "minimal");
void wrapped(
{
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
} as Model<"openai-responses">,
{ messages: [] },
{},
);
expect(payloads[0]?.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it.each([
{
api: "openai-responses",

View File

@@ -9,6 +9,7 @@ import {
resolveCodexNativeSearchActivation,
} from "../codex-native-web-search.js";
import { flattenCompletionMessagesToStringContent } from "../openai-completions-string-content.js";
import { resolveOpenAIReasoningEffortForModel } from "../openai-reasoning-effort.js";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
@@ -85,6 +86,66 @@ function shouldFlattenOpenAICompletionMessages(model: {
return model.api === "openai-completions" && compat?.requiresStringContent === true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasResponsesWebSearchTool(tools: unknown): boolean {
if (!Array.isArray(tools)) {
return false;
}
return tools.some((tool) => {
if (!isRecord(tool)) {
return false;
}
if (tool.type === "web_search") {
return true;
}
if (tool.type === "function" && tool.name === "web_search") {
return true;
}
const fn = tool.function;
return isRecord(fn) && fn.name === "web_search";
});
}
function resolveOpenAIThinkingPayloadEffort(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
thinkingLevel: ThinkLevel;
}) {
const mapped = mapThinkingLevelToReasoningEffort(params.thinkingLevel);
if (mapped !== "minimal" || !hasResponsesWebSearchTool(params.payloadObj.tools)) {
return mapped;
}
return (
resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
}) ?? mapped
);
}
function raiseMinimalReasoningForResponsesWebSearchPayload(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
}): void {
const reasoning = params.payloadObj.reasoning;
if (!isRecord(reasoning) || reasoning.effort !== "minimal") {
return;
}
if (!hasResponsesWebSearchTool(params.payloadObj.tools)) {
return;
}
const nextEffort = resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
});
if (nextEffort && nextEffort !== "minimal" && nextEffort !== "none") {
reasoning.effort = nextEffort;
}
}
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
if (typeof value !== "string") {
return undefined;
@@ -240,7 +301,12 @@ export function createOpenAIThinkingLevelWrapper(
}
return (model, context, options) => {
if (!shouldApplyOpenAIReasoningCompatibility(model)) {
return underlying(model, context, options);
if (thinkingLevel === "off") {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
});
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
const existingReasoning = payloadObj.reasoning;
@@ -251,8 +317,13 @@ export function createOpenAIThinkingLevelWrapper(
return;
}
const reasoningEffort = resolveOpenAIThinkingPayloadEffort({
model,
payloadObj,
thinkingLevel,
});
if (existingReasoning === "none") {
payloadObj.reasoning = { effort: mapThinkingLevelToReasoningEffort(thinkingLevel) };
payloadObj.reasoning = { effort: reasoningEffort };
return;
}
if (
@@ -260,8 +331,8 @@ export function createOpenAIThinkingLevelWrapper(
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
(existingReasoning as Record<string, unknown>).effort =
mapThinkingLevelToReasoningEffort(thinkingLevel);
(existingReasoning as Record<string, unknown>).effort = reasoningEffort;
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
}
});
};

View File

@@ -117,6 +117,12 @@ describe("shouldCreateBundleMcpRuntimeForAttempt", () => {
toolsAllow: ["memory_search", "memory_get"],
}),
).toBe(false);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,
toolsAllow: ["bundle-mcp"],
}),
).toBe(true);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,

View File

@@ -490,7 +490,9 @@ export function shouldCreateBundleMcpRuntimeForAttempt(params: {
if (!params.toolsAllow || params.toolsAllow.length === 0) {
return true;
}
return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR));
return params.toolsAllow.some(
(toolName) => toolName === "bundle-mcp" || toolName.includes(TOOL_NAME_SEPARATOR),
);
}
function collectAttemptExplicitToolAllowlistSources(params: {

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { redactSensitiveText } from "../logging/redact.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
applyInputProvenanceToUserMessage,
@@ -16,6 +17,71 @@ export type GuardedSessionManager = SessionManager & {
clearPendingToolResults?: () => void;
};
function redactTranscriptText(value: string, cfg?: OpenClawConfig): string {
if (cfg?.logging?.redactSensitive === "off") {
return value;
}
return redactSensitiveText(value, {
mode: cfg?.logging?.redactSensitive,
patterns: cfg?.logging?.redactPatterns,
});
}
function redactTranscriptContentBlock(block: unknown, cfg?: OpenClawConfig): unknown {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return block;
}
const source = block as Record<string, unknown>;
let next: Record<string, unknown> | null = null;
const assign = (key: string, value: string) => {
const redacted = redactTranscriptText(value, cfg);
if (redacted === value) {
return;
}
next ??= { ...source };
next[key] = redacted;
};
if (typeof source.text === "string") {
assign("text", source.text);
}
if (typeof source.thinking === "string") {
assign("thinking", source.thinking);
}
if (typeof source.partialJson === "string") {
assign("partialJson", source.partialJson);
}
return next ?? block;
}
function redactTranscriptContent(content: unknown, cfg?: OpenClawConfig): unknown {
if (typeof content === "string") {
return redactTranscriptText(content, cfg);
}
if (!Array.isArray(content)) {
return content;
}
let changed = false;
const redacted = content.map((block) => {
const next = redactTranscriptContentBlock(block, cfg);
changed ||= next !== block;
return next;
});
return changed ? redacted : content;
}
function redactTranscriptMessage(message: AgentMessage, cfg?: OpenClawConfig): AgentMessage {
const source = message as unknown as Record<string, unknown>;
const redactedContent = redactTranscriptContent(source.content, cfg);
if (redactedContent === source.content) {
return message;
}
return {
...source,
content: redactedContent,
} as unknown as AgentMessage;
}
/**
* Apply the tool-result guard to a SessionManager exactly once and expose
* a flush method on the instance for easy teardown handling.
@@ -38,14 +104,31 @@ export function guardSessionManager(
}
const hookRunner = getGlobalHookRunner();
const beforeMessageWrite = hookRunner?.hasHooks("before_message_write")
? (event: { message: import("@mariozechner/pi-agent-core").AgentMessage }) => {
return hookRunner.runBeforeMessageWrite(event, {
agentId: opts?.agentId,
sessionKey: opts?.sessionKey,
});
const beforeMessageWrite = (event: {
message: import("@mariozechner/pi-agent-core").AgentMessage;
}) => {
let message = event.message;
let changed = false;
if (hookRunner?.hasHooks("before_message_write")) {
const result = hookRunner.runBeforeMessageWrite(event, {
agentId: opts?.agentId,
sessionKey: opts?.sessionKey,
});
if (result?.block) {
return result;
}
: undefined;
if (result?.message) {
message = result.message;
changed = true;
}
}
const redacted = redactTranscriptMessage(message, opts?.config);
if (redacted !== message) {
message = redacted;
changed = true;
}
return changed ? { message } : undefined;
};
const transform = hookRunner?.hasHooks("tool_result_persist")
? (

View File

@@ -1,11 +1,34 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SkillsChangeEvent } from "./refresh.js";
const watchMock = vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
}));
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
type WatchCallback = (watchPath: string) => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent, watchPath: string) => {
for (const callback of handlers.get(event) ?? []) {
callback(watchPath);
}
},
};
return watcher;
}
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
const watchMock = vi.fn(() => {
const watcher = createMockWatcher();
createdWatchers.push(watcher);
return watcher;
});
let refreshModule: typeof import("./refresh.js");
@@ -24,13 +47,15 @@ describe("ensureSkillsWatcher", () => {
beforeEach(() => {
watchMock.mockClear();
createdWatchers.length = 0;
});
afterEach(async () => {
vi.useRealTimers();
await refreshModule.resetSkillsRefreshForTest();
});
it("ignores node_modules, dist, .git, and Python venvs by default", async () => {
it("watches skill roots and filters non-skill churn", async () => {
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
expect(watchMock).toHaveBeenCalledTimes(1);
@@ -40,49 +65,64 @@ describe("ensureSkillsWatcher", () => {
const targets = firstCall?.[0] ?? [];
const opts = firstCall?.[1] ?? {};
expect(opts.ignored).toBe(refreshModule.DEFAULT_SKILLS_WATCH_IGNORED);
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
const posix = (p: string) => p.replaceAll("\\", "/");
expect(targets).toEqual(
expect.arrayContaining([
posix(path.join("/tmp/workspace", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "*", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills")),
posix(path.join("/tmp/workspace", ".agents", "skills")),
posix(path.join(os.homedir(), ".agents", "skills")),
]),
);
expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true);
const ignored = refreshModule.DEFAULT_SKILLS_WATCH_IGNORED;
expect(targets.every((target) => !target.includes("*"))).toBe(true);
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
// Node/JS paths
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
// Python virtual environments and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/scripts/.venv/bin/python"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/venv/lib/python3.10/site.py"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/__pycache__/module.pyc"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.mypy_cache/3.10/foo.json"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.pytest_cache/v/cache"))).toBe(true);
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
// Build artifacts and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/build/output.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.cache/data.json"))).toBe(true);
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
// Should NOT ignore normal skill files
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/my-skill/SKILL.md"))).toBe(false);
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"refreshes skills snapshots on %s",
async (event) => {
vi.useFakeTimers();
const seen: SkillsChangeEvent[] = [];
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { watchDebounceMs: 10 } } },
});
createdWatchers[0]?.emit(event, "/tmp/workspace/skills/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
expect(seen).toEqual([
{
workspaceDir: "/tmp/workspace",
reason: "watch",
changedPath: "/tmp/workspace/skills/demo/SKILL.md",
},
]);
},
);
});

View File

@@ -72,26 +72,36 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
return paths;
}
function toWatchGlobRoot(raw: string): string {
// Chokidar treats globs as POSIX-ish patterns. Normalize Windows separators
// so `*` works consistently across platforms.
return raw.replaceAll("\\", "/").replace(/\/+$/, "");
function toWatchRoot(raw: string): string {
const normalized = raw.replaceAll("\\", "/");
return normalized.replace(/\/+$/, "") || normalized;
}
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] {
// Skills are defined by SKILL.md; watch only those files to avoid traversing
// or watching unrelated large trees (e.g. datasets) that can exhaust FDs.
const targets = new Set<string>();
for (const root of resolveWatchPaths(workspaceDir, config)) {
const globRoot = toWatchGlobRoot(root);
// Some configs point directly at a skill folder.
targets.add(`${globRoot}/SKILL.md`);
// Standard layout: <skillsRoot>/<skillName>/SKILL.md
targets.add(`${globRoot}/*/SKILL.md`);
targets.add(toWatchRoot(root));
}
return Array.from(targets).toSorted();
}
export function shouldIgnoreSkillsWatchPath(
watchPath: string,
stats?: { isDirectory?: () => boolean },
): boolean {
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) {
return true;
}
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const normalized = watchPath.replaceAll("\\", "/");
return path.posix.basename(normalized) !== "SKILL.md";
}
export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
const workspaceDir = params.workspaceDir.trim();
if (!workspaceDir) {
@@ -135,9 +145,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
stabilityThreshold: debounceMs,
pollInterval: 100,
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to SKILL.md changes.
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
ignored: shouldIgnoreSkillsWatchPath,
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
@@ -162,6 +170,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
watcher.on("add", (p) => schedule(p));
watcher.on("change", (p) => schedule(p));
watcher.on("unlink", (p) => schedule(p));
watcher.on("unlinkDir", (p) => schedule(p));
watcher.on("error", (err) => {
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
});

View File

@@ -3392,6 +3392,95 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("poisons inbound dedupe when dispatch fails after a block reply", async () => {
setNoAbort();
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550125",
To: "whatsapp:+15555550125",
AccountId: "default",
MessageSid: "msg-dup-block-error",
SessionKey: "agent:main:whatsapp:direct:+15555550125",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onBlockReply?.({ text: "partial answer" });
throw new Error("provider failed after block");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after block");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "partial answer" });
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("poisons inbound dedupe when dispatch fails after a suppressed tool result", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "deny",
};
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550126",
To: "whatsapp:+15555550126",
AccountId: "default",
MessageSid: "msg-dup-tool-error",
SessionKey: "agent:main:whatsapp:direct:+15555550126",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onToolResult?.({ text: "tool touched external state" });
throw new Error("provider failed after tool");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after tool");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendToolResult).not.toHaveBeenCalled();
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("passes configOverride to replyResolver when provided", async () => {
setNoAbort();
const cfg = emptyConfig;

View File

@@ -343,6 +343,10 @@ export async function dispatchReplyFromConfig(
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
let inboundDedupeReplayUnsafe = false;
const markInboundDedupeReplayUnsafe = () => {
inboundDedupeReplayUnsafe = true;
};
const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg);
const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg });
@@ -473,6 +477,7 @@ export async function dispatchReplyFromConfig(
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
return null;
}
markInboundDedupeReplayUnsafe();
return await routeReplyRuntime.routeReply({
payload,
channel: routeReplyChannel,
@@ -538,6 +543,7 @@ export async function dispatchReplyFromConfig(
}
return result.ok;
}
markInboundDedupeReplayUnsafe();
return mode === "additive"
? dispatcher.sendToolResult(payload)
: dispatcher.sendFinalReply(payload);
@@ -721,6 +727,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
queuedFinal = dispatcher.sendFinalReply(payload);
}
} else {
@@ -744,6 +751,9 @@ export async function dispatchReplyFromConfig(
const sendFinalPayload = async (
payload: ReplyPayload,
): Promise<{ queuedFinal: boolean; routedFinalCount: number }> => {
if (resolveSendableOutboundReplyParts(payload).hasContent) {
markInboundDedupeReplayUnsafe();
}
const ttsPayload = await maybeApplyTtsToReplyPayload({
payload,
cfg,
@@ -767,6 +777,7 @@ export async function dispatchReplyFromConfig(
routedFinalCount: result.ok ? 1 : 0,
};
}
markInboundDedupeReplayUnsafe();
return {
queuedFinal: dispatcher.sendFinalReply(normalizedPayload),
routedFinalCount: 0,
@@ -898,6 +909,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(payload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(payload);
};
const sendPlanUpdate = async (payload: {
@@ -914,6 +926,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(replyPayload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(replyPayload);
};
const summarizeApprovalLabel = (payload: {
@@ -1019,6 +1032,7 @@ export async function dispatchReplyFromConfig(
suppressTyping: typing.suppressTyping,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
markInboundDedupeReplayUnsafe();
await onToolResultFromReplyOptions?.(payload);
if (suppressDelivery) {
return;
@@ -1055,12 +1069,14 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(deliveryPayload, undefined, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(deliveryPayload);
}
};
return run();
},
onPlanUpdate: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPlanUpdateFromReplyOptions?.(payload);
if (payload.phase !== "update" || suppressDefaultToolProgressMessages) {
return;
@@ -1068,6 +1084,7 @@ export async function dispatchReplyFromConfig(
await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps });
},
onApprovalEvent: async (payload) => {
markInboundDedupeReplayUnsafe();
await onApprovalEventFromReplyOptions?.(payload);
if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) {
return;
@@ -1083,6 +1100,7 @@ export async function dispatchReplyFromConfig(
await maybeSendWorkingStatus(label);
},
onPatchSummary: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPatchSummaryFromReplyOptions?.(payload);
if (payload.phase !== "end" || suppressDefaultToolProgressMessages) {
return;
@@ -1095,6 +1113,12 @@ export async function dispatchReplyFromConfig(
},
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
const run = async () => {
if (
payload.isReasoning !== true &&
resolveSendableOutboundReplyParts(payload).hasContent
) {
markInboundDedupeReplayUnsafe();
}
if (suppressDelivery) {
return;
}
@@ -1156,6 +1180,7 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(normalizedPayload, context?.abortSignal, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendBlockReply(normalizedPayload);
}
};
@@ -1268,6 +1293,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
const didQueue = dispatcher.sendFinalReply(normalizedTtsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
@@ -1293,7 +1319,11 @@ export async function dispatchReplyFromConfig(
return { queuedFinal, counts };
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
releaseInboundDedupe(inboundDedupeClaim.key);
if (inboundDedupeReplayUnsafe) {
commitInboundDedupe(inboundDedupeClaim.key);
} else {
releaseInboundDedupe(inboundDedupeClaim.key);
}
}
recordProcessed("error", { error: String(err) });
markIdle("message_error");

View File

@@ -72,4 +72,33 @@ describe("inbound dedupe", () => {
inboundB.resetInboundDedupe();
}
});
it("shares claim/commit state across distinct module instances", async () => {
const inboundA = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-a",
);
const inboundB = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-b",
);
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
try {
const firstClaim = inboundA.claimInboundDedupe(sharedInboundContext);
expect(firstClaim).toMatchObject({ status: "claimed" });
if (firstClaim.status !== "claimed") {
throw new Error("expected claimed inbound dedupe result");
}
inboundA.commitInboundDedupe(firstClaim.key);
expect(inboundB.claimInboundDedupe(sharedInboundContext)).toMatchObject({
status: "duplicate",
});
} finally {
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
}
});
});

View File

@@ -1502,6 +1502,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -2682,6 +2857,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -3792,6 +4142,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
default: true,
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
@@ -4345,6 +4767,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
resolveSenderNames: {
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],

View File

@@ -466,7 +466,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
],
title: "Sensitive Data Redaction Mode",
description:
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
},
redactPatterns: {
type: "array",
@@ -475,7 +475,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
title: "Custom Redaction Patterns",
description:
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
},
},
additionalProperties: false,
@@ -23982,12 +23982,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"logging.redactSensitive": {
label: "Sensitive Data Redaction Mode",
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
help: 'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
tags: ["privacy", "observability"],
},
"logging.redactPatterns": {
label: "Custom Redaction Patterns",
help: "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
help: "Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
tags: ["privacy", "observability"],
},
"cli.banner": {
@@ -28617,6 +28617,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.26",
version: "2026.4.25-beta.10",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -43,9 +43,9 @@ export const FIELD_HELP: Record<string, string> = {
"logging.consoleStyle":
'Console output format style: "pretty", "compact", or "json" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.',
"logging.redactSensitive":
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields. Keep "tools" in shared logs unless you have isolated secure log sinks.',
'Sensitive redaction mode: "off" disables built-in masking, while "tools" redacts sensitive tool/config payload fields in log sinks and persisted transcript text. Keep "tools" enabled unless logs and transcripts are isolated.',
"logging.redactPatterns":
"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
"Additional custom redact regex patterns applied to log output and persisted transcript text before storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.",
cli: "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.",
"cli.banner":
"CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.",

View File

@@ -225,9 +225,9 @@ export type LoggingConfig = {
maxFileBytes?: number;
consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
consoleStyle?: "pretty" | "compact" | "json";
/** Redact sensitive tokens in tool summaries. Default: "tools". */
/** Redact sensitive tokens in log sinks and persisted transcript text. Default: "tools". */
redactSensitive?: "off" | "tools";
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
/** Regex patterns used to redact sensitive tokens from logs and transcripts. */
redactPatterns?: string[];
};

View File

@@ -437,6 +437,31 @@ describe("channel-health-monitor", () => {
monitor.stop();
});
it("counts failed restart attempts toward cooldown and hourly caps", async () => {
const manager = createSnapshotManager(
{
discord: {
default: managedStoppedAccount("keeps crashing"),
},
},
{
startChannel: vi.fn(async () => {
throw new Error("startup failed");
}),
},
);
const monitor = startDefaultMonitor(manager, {
checkIntervalMs: 1_000,
cooldownCycles: 1,
maxRestartsPerHour: 1,
});
await vi.advanceTimersByTimeAsync(5_001);
expect(manager.startChannel).toHaveBeenCalledTimes(1);
monitor.stop();
});
it("runs checks single-flight when restart work is still in progress", async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {

View File

@@ -157,15 +157,16 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
try {
if (status.running) {
await channelManager.stopChannel(channelId as ChannelId, accountId);
}
channelManager.resetRestartAttempts(channelId as ChannelId, accountId);
await channelManager.startChannel(channelId as ChannelId, accountId);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
} catch (err) {
log.error?.(
`[${channelId}:${accountId}] health-monitor: restart failed: ${String(err)}`,

View File

@@ -36,6 +36,9 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
const CONNECT_TIMEOUT_MS = 90_000;
const LIVE_TIMEOUT_MS = 240_000;
const ACP_CRON_MCP_PROBE_MAX_ATTEMPTS = 2;
const ACP_CRON_MCP_PROBE_VERIFY_POLLS = 5;
const ACP_CRON_MCP_PROBE_VERIFY_POLL_MS = 1_000;
const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
type LiveAcpAgent = "claude" | "codex" | "droid" | "gemini" | "opencode";
@@ -150,6 +153,10 @@ function shouldRequireBoundAssistantTranscript(liveAgent: LiveAcpAgent): boolean
);
}
function shouldRequireCronMcpProbe(): boolean {
return isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON);
}
function normalizeOpenAiModelRef(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -287,24 +294,30 @@ async function bindConversationAndWait(params: {
doctor?: () => Promise<{ message?: string; details?: string[] }>;
}
| undefined;
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
if (!(backend?.healthy?.() ?? false)) {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
const backendUnavailable = !backend || (backend.healthy && !backend.healthy());
if (backendUnavailable) {
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
const backendReadyAfterProbe = backend && (!backend.healthy || backend.healthy());
if (backendReadyAfterProbe) {
logLiveStep(`acpx backend became healthy before bind attempt ${attempt}`);
} else {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
await sendChatAndWait({
@@ -463,6 +476,25 @@ async function waitForAssistantTurn(params: {
);
}
async function pollCronJobVisibleViaCli(params: {
port: number;
token: string;
env: NodeJS.ProcessEnv;
expectedName: string;
expectedMessage: string;
}): Promise<{ job?: Awaited<ReturnType<typeof assertCronJobVisibleViaCli>>; pollsUsed: number }> {
for (let verifyAttempt = 0; verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS; verifyAttempt += 1) {
const job = await assertCronJobVisibleViaCli(params);
if (job) {
return { job, pollsUsed: verifyAttempt + 1 };
}
if (verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS - 1) {
await sleep(ACP_CRON_MCP_PROBE_VERIFY_POLL_MS);
}
}
return { pollsUsed: ACP_CRON_MCP_PROBE_VERIFY_POLLS };
}
describeLive("gateway live (ACP bind)", () => {
it(
"binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn",
@@ -852,9 +884,10 @@ describeLive("gateway live (ACP bind)", () => {
agentId: liveAgent,
sessionKey: spawnedSessionKey,
});
const requireCronMcpProbe = shouldRequireCronMcpProbe();
let cronJobId: string | undefined;
let lastCronAssistantText = "";
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < ACP_CRON_MCP_PROBE_MAX_ATTEMPTS; attempt += 1) {
await sendChatAndWait({
client,
sessionKey: originalSessionKey,
@@ -876,7 +909,7 @@ describeLive("gateway live (ACP bind)", () => {
cronHistory = await waitForAssistantText({
client,
sessionKey: spawnedSessionKey,
timeoutMs: liveAgent === "claude" ? 90_000 : 45_000,
timeoutMs: 20_000,
contains: cronProbe.name,
});
} catch {
@@ -885,13 +918,14 @@ describeLive("gateway live (ACP bind)", () => {
if (cronHistory) {
lastCronAssistantText = cronHistory.lastAssistantText;
}
const createdJob = await assertCronJobVisibleViaCli({
const verifyResult = await pollCronJobVisibleViaCli({
port,
token,
env: process.env,
expectedName: cronProbe.name,
expectedMessage: cronProbe.message,
});
const createdJob = verifyResult.job;
if (createdJob) {
assertCronJobMatches({
job: createdJob,
@@ -906,10 +940,15 @@ describeLive("gateway live (ACP bind)", () => {
}
break;
}
if (attempt === 1) {
if (liveAgent !== "claude") {
logLiveStep(
`cron mcp job not observed after attempt ${String(
attempt + 1,
)}; polls=${String(verifyResult.pollsUsed)}`,
);
if (attempt === ACP_CRON_MCP_PROBE_MAX_ATTEMPTS - 1) {
if (!requireCronMcpProbe) {
logLiveStep(
`cron mcp job ${cronProbe.name} not observed for ${liveAgent}; continuing after bind/image verification`,
`cron mcp job ${cronProbe.name} not observed; continuing after bind/image verification`,
);
break;
}
@@ -921,7 +960,7 @@ describeLive("gateway live (ACP bind)", () => {
}
}
if (!cronJobId) {
if (liveAgent !== "claude") {
if (!requireCronMcpProbe) {
return;
}
throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`);

View File

@@ -31,6 +31,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js";
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js";
import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js";
import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -736,6 +737,16 @@ describe("shouldSkipEmptyResponseForLiveModel", () => {
);
});
describe("isEmptyStreamText", () => {
it.each([
{ text: "request ended without sending any chunks", expected: true },
{ text: `not meaningful: ${STREAM_ERROR_FALLBACK_TEXT}`, expected: true },
{ text: "not meaningful: let me think", expected: false },
])("returns $expected for $text", ({ text, expected }) => {
expect(isEmptyStreamText(text)).toBe(expected);
});
});
describe("isPromptProbeMiss", () => {
it.each([
{ error: "not meaningful: let me think", expected: true },
@@ -763,7 +774,10 @@ function isMissingProfileError(error: string): boolean {
}
function isEmptyStreamText(text: string): boolean {
return text.includes("request ended without sending any chunks");
return (
text.includes("request ended without sending any chunks") ||
text.includes(STREAM_ERROR_FALLBACK_TEXT)
);
}
function buildAnthropicRefusalToken(): string {

View File

@@ -74,6 +74,7 @@ export function buildLiveCronProbeMessage(params: {
if (params.attempt === 0) {
return (
"Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Call it with JSON arguments ${params.argsJson}. ` +
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
@@ -83,6 +84,7 @@ export function buildLiveCronProbeMessage(params: {
if (claudeLike) {
return (
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
@@ -94,6 +96,7 @@ export function buildLiveCronProbeMessage(params: {
return (
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +

View File

@@ -50,6 +50,7 @@ function createTestPlugin(params?: {
order?: number;
account?: TestAccount;
startAccount?: NonNullable<ChannelPlugin<TestAccount>["gateway"]>["startAccount"];
listAccountIds?: ChannelPlugin<TestAccount>["config"]["listAccountIds"];
includeDescribeAccount?: boolean;
describeAccount?: ChannelPlugin<TestAccount>["config"]["describeAccount"];
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
@@ -59,7 +60,7 @@ function createTestPlugin(params?: {
const account = params?.account ?? { enabled: true, configured: true };
const includeDescribeAccount = params?.includeDescribeAccount !== false;
const config: ChannelPlugin<TestAccount>["config"] = {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
listAccountIds: params?.listAccountIds ?? (() => [DEFAULT_ACCOUNT_ID]),
resolveAccount: params?.resolveAccount ?? (() => account),
isEnabled: (resolved) => resolved.enabled !== false,
...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}),
@@ -436,6 +437,35 @@ describe("server-channels auto restart", () => {
expect(succeedingStart).toHaveBeenCalledTimes(1);
});
it("evicts stale account lifecycle state during whole-channel reload", async () => {
let accountIds = [DEFAULT_ACCOUNT_ID];
const startAccount = vi.fn(
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
await new Promise<void>((resolve) => {
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
installTestRegistry(createTestPlugin({ startAccount, listAccountIds: () => accountIds }));
const manager = createManager();
await manager.startChannel("discord");
accountIds = [];
await manager.stopChannel("discord");
await manager.startChannel("discord");
accountIds = [DEFAULT_ACCOUNT_ID];
await manager.startChannel("discord");
const snapshot = manager.getRuntimeSnapshot();
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(2);
expect(account?.reconnectAttempts).toBe(0);
expect(account?.lastStopAt).toBeUndefined();
await manager.stopChannel("discord");
});
it("reuses plugin account resolution for health monitor overrides", () => {
installTestRegistry(
createTestPlugin({

View File

@@ -282,6 +282,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
return channelRuntime ?? resolveChannelRuntime?.();
};
const evictStaleChannelAccountState = (
channelId: ChannelId,
store: ChannelRuntimeStore,
accountIds: readonly string[],
) => {
const activeAccountIds = new Set(accountIds);
for (const id of store.runtimes.keys()) {
if (
activeAccountIds.has(id) ||
store.aborts.has(id) ||
store.starting.has(id) ||
store.tasks.has(id)
) {
continue;
}
store.runtimes.delete(id);
restartAttempts.delete(restartKey(channelId, id));
manuallyStopped.delete(restartKey(channelId, id));
}
};
const startChannelInternal = async (
channelId: ChannelId,
accountId?: string,
@@ -297,6 +318,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
resetDirectoryCache({ channel: channelId, accountId });
const store = getStore(channelId);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
if (!accountId) {
evictStaleChannelAccountState(channelId, store, accountIds);
}
if (accountIds.length === 0) {
return;
}

View File

@@ -132,6 +132,16 @@ describe("redactSensitiveText", () => {
expect(output).toBe("token=abcdef…ghij");
});
it("honors escaped character classes in custom patterns", () => {
const input = "contact peter@dc.io";
const output = redactSensitiveText(input, {
mode: "tools",
patterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`],
});
expect(output).toBe("contact peter@d***.io");
expect(output).not.toContain("peter@dc.io");
});
it("ignores unsafe nested-repetition custom patterns", () => {
const input = `${"a".repeat(28)}!`;
const output = redactSensitiveText(input, {

View File

@@ -312,10 +312,6 @@ describe("getCachedPluginJitiLoader", () => {
const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown;
loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
expect(jitiLoader).toHaveBeenCalledWith(
"/repo/dist/extensions/demo/api.js",
{ hint: "x" },
42,
);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
});
});

View File

@@ -12,6 +12,7 @@ describe("safe regex", () => {
["(a|aa)+$", true],
["^(?:foo|bar)$", false],
["^(ab|cd)+$", false],
[String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`, false],
] as const)("classifies nested repetition for %s", (pattern, expected) => {
expect(hasNestedRepetition(pattern)).toBe(expected);
});

View File

@@ -140,19 +140,23 @@ function tokenizePattern(source: string): PatternToken[] {
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (ch === "\\") {
i += 1;
tokens.push({ kind: "simple-token" });
continue;
}
if (inCharClass) {
if (ch === "\\") {
i += 1;
continue;
}
if (ch === "]") {
inCharClass = false;
}
continue;
}
if (ch === "\\") {
i += 1;
tokens.push({ kind: "simple-token" });
continue;
}
if (ch === "[") {
inCharClass = true;
tokens.push({ kind: "simple-token" });

View File

@@ -363,7 +363,7 @@ export async function waitForChatFinalEvent(params: {
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
const deadline = Date.now() + (params.timeoutMs ?? 45_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>