Compare commits

...

100 Commits

Author SHA1 Message Date
Peter Steinberger
eb8a8840d6 chore(release): prepare 2026.3.2-beta.1 2026-03-03 03:38:49 +00:00
Runkun Miao
7c6f8bfe73 feat(feishu): add broadcast support for multi-agent groups (#29575)
* feat(feishu): add broadcast support for multi-agent group observation

When multiple agents share a Feishu group chat, only the @mentioned
agent receives the message. This prevents observer agents from building
session memory of group activity they weren't directly addressed in.

Adds broadcast support (reusing the same cfg.broadcast schema as
WhatsApp) so all configured agents receive every group message in their
session transcripts. Only the @mentioned agent responds on Feishu;
observer agents process silently via no-op dispatchers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): guard sequential broadcast dispatch against single-agent failure

Wrap each dispatchForAgent() call in the sequential loop with try/catch
so one agent's dispatch failure doesn't abort delivery to remaining agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): avoid duplicate messages in broadcast observer mode and normalize agent IDs

- Skip recordPendingHistoryEntryIfEnabled for broadcast groups when not
  mentioned, since the message is dispatched directly to all agents.
  Previously the message appeared twice in the agent prompt.
- Normalize agent IDs with toLowerCase() before membership checks so
  config casing mismatches don't silently skip valid agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): set WasMentioned per-agent and normalize broadcast IDs

- buildCtxPayloadForAgent now takes a wasMentioned parameter so active
  agents get WasMentioned=true and observers get false (P1 fix)
- Normalize broadcastAgents to lowercase at resolution time and
  lowercase activeAgentId so all comparisons and session key generation
  use canonical IDs regardless of config casing (P2 fix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): canonicalize broadcast agent IDs with normalizeAgentId

* fix(feishu): match ReplyDispatcher sync return types for noop dispatcher

The upstream ReplyDispatcher changed sendToolResult/sendBlockReply/
sendFinalReply to synchronous (returning boolean). Update the broadcast
observer noop dispatcher to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): deduplicate broadcast agent IDs after normalization

Config entries like "Main" and "main" collapse to the same canonical ID
after normalizeAgentId but were dispatched multiple times. Use Set to
deduplicate after normalization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): honor requireMention=false when selecting broadcast responder

When requireMention is false, the routed agent should be active (reply
on Feishu) even without an explicit @mention. Previously activeAgentId
was null whenever ctx.mentionedBot was false, so all agents got the
noop dispatcher and no reply was sent — silently breaking groups that
disabled mention gating.

Hoist requireMention out of the if(isGroup) block so it's accessible
in the dispatch code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): cross-account broadcast dedup to prevent duplicate dispatches

In multi-account Feishu setups, the same message event is delivered to
every bot account in a group. Without cross-account dedup, each account
independently dispatches broadcast agents, causing 2×N dispatches instead
of N (where N = number of broadcast agents).

Two changes:
1. requireMention=true + bot not mentioned: return early instead of
   falling through to broadcast. The mentioned bot's handler will
   dispatch for all agents. Non-mentioned handlers record to history.
2. Add cross-account broadcast dedup using a shared 'broadcast' namespace
   (tryRecordMessagePersistent). The first handler to reach the broadcast
   block claims the message; subsequent accounts skip. This handles the
   requireMention=false multi-account case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): strip CommandAuthorized from broadcast observer contexts

Broadcast observer agents inherited CommandAuthorized from the sender,
causing slash commands (e.g. /reset) to silently execute on every observer
session. Now only the active agent retains CommandAuthorized; observers
have it stripped before dispatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): use actual mention state for broadcast WasMentioned

The active broadcast agent's WasMentioned was set to true whenever
requireMention=false, even when the bot was not actually @mentioned.
Now uses ctx.mentionedBot && agentId === activeAgentId, consistent
with the single-agent path which passes ctx.mentionedBot directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): skip history buffer for broadcast accounts and log parallel failures

1. In requireMention groups with broadcast, non-mentioned accounts no
   longer buffer pending history — the mentioned handler's broadcast
   dispatch already writes turns into all agent sessions. Buffering
   caused duplicate replay via buildPendingHistoryContextFromMap.

2. Parallel broadcast dispatch now inspects Promise.allSettled results
   and logs rejected entries, matching the sequential path's per-agent
   error logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Changelog: note Feishu multi-agent broadcast dispatch

* Changelog: restore author credit for Feishu broadcast entry

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 21:38:46 -06:00
Peter Steinberger
92c4a2a29e fix(gateway): retry exec-read live tool probe 2026-03-03 03:36:55 +00:00
Peter Steinberger
70ab91500a test(ci): add changed-scope shell-injection regression 2026-03-03 03:34:51 +00:00
Peter Steinberger
f175a5d6d3 fix(ci): avoid shell interpolation in changed-scope git diff 2026-03-03 03:34:46 +00:00
xbsheng
02d26ced98 docs(feishu): Feishu docs – add verificationToken and align zh-CN with EN (openclaw#31555) thanks @xbsheng
Verified:
- pnpm build
- pnpm test:macmini
- pnpm check (blocked locally by pre-existing mainline lint issue in src/scripts/ci-changed-scope.test.ts unrelated to this PR)

Co-authored-by: xbsheng <56357338+xbsheng@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 21:33:41 -06:00
Vincent Koc
99a48aad08 CI: increase checks-windows test shards to 4 2026-03-02 19:32:46 -08:00
Vincent Koc
8b80848ae9 CI: increase checks-windows test shards to 3 2026-03-02 19:31:27 -08:00
Vincent Koc
153a4f55db CI: reduce pre-test Windows setup latency 2026-03-02 19:30:29 -08:00
Vincent Koc
578a7a82be CI: add exact-key mode for pnpm cache restore 2026-03-02 19:30:29 -08:00
Sid
e6f34b25aa fix(feishu): preserve block streaming text when final payload is missing (#30663)
* fix(feishu): preserve block streaming text when final payload is missing

When Feishu card streaming receives block payloads without matching final/partial
callbacks, keep block text in stream state so onIdle close still publishes the
reply instead of an empty message. Add a regression test for block-only streaming.

Closes #30628

* Feishu: preserve streaming block fallback when final text is missing

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 21:26:21 -06:00
Peter Steinberger
17bb87f432 fix(venice): retry model discovery on transient fetch failures 2026-03-03 03:21:00 +00:00
joshavant
85a320de54 docs(changelog): add SecretRef note for #29580 2026-03-02 21:19:17 -06:00
Peter Steinberger
46b62c53f0 fix(ci): restore scope-test require import and sync host policy 2026-03-03 03:18:45 +00:00
Peter Steinberger
ca1b50908f chore(gitignore): ignore android kotlin cache 2026-03-03 03:13:23 +00:00
Vincent Koc
05aa16c040 CI: allow blacksmith 32 vCPU Windows runner in actionlint 2026-03-02 19:13:14 -08:00
Vincent Koc
2c6616b830 CI: gate Windows checks by windows-relevant scope (#32456)
* CI: add windows scope output for changed-scope

* Test: cover windows scope gating in changed-scope

* CI: gate checks-windows by windows scope

* Docs: update CI windows scope and runner label

* CI: move checks-windows to 32 vCPU runner

* Docs: align CI windows runner with workflow
2026-03-02 19:10:58 -08:00
Peter Steinberger
80efcb75c7 style(swift): apply lint and format cleanup 2026-03-03 03:07:55 +00:00
Peter Steinberger
ba50dfaae3 refactor(macos): simplify pairing alert and host helper paths 2026-03-03 03:07:54 +00:00
Peter Steinberger
04a8f97c57 fix(swift): align async helper callsites across iOS and macOS 2026-03-03 03:07:54 +00:00
Peter Steinberger
5cba9a6bab test: load ci changed-scope script via esm import 2026-03-03 03:06:22 +00:00
Peter Steinberger
da6e6fb900 test: fix strict runtime mock types in channel tests 2026-03-03 03:06:22 +00:00
Peter Steinberger
805de8537c fix(telegram): move unchanged command-sync log to verbose 2026-03-03 03:05:39 +00:00
Peter Steinberger
f7f0caa5c7 fix(ci): tighten type signatures in gateway params validation 2026-03-03 03:04:13 +00:00
Peter Steinberger
7fd4328854 fix(e2e): include shared tool display resource in onboard docker build 2026-03-03 03:02:27 +00:00
Peter Steinberger
7bad42910b docs: reorder unreleased changelog by user interest 2026-03-03 03:00:37 +00:00
Vincent Koc
f2c37e543e CI: optimize Windows lane by splitting bundle and dropping duplicate lanes 2026-03-02 18:58:43 -08:00
Josh Avant
806803b7ef feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-03 02:58:20 +00:00
Peter Steinberger
f212351aed refactor(telegram): dedupe monitor retry test helpers 2026-03-03 02:53:14 +00:00
Peter Steinberger
6408b7f81c refactor(agents): dedupe steer restart test replacement flow 2026-03-03 02:53:14 +00:00
Peter Steinberger
1538813096 refactor(agents): dedupe ollama provider test scaffolding 2026-03-03 02:53:14 +00:00
Peter Steinberger
55c128ddc2 refactor(memory): dedupe readonly recovery test scenarios 2026-03-03 02:53:14 +00:00
Peter Steinberger
3ff0cf262d refactor(infra): dedupe update startup test setup 2026-03-03 02:53:14 +00:00
Peter Steinberger
a50dd0bb06 refactor(infra): dedupe ssrf fetch guard test fixtures 2026-03-03 02:53:13 +00:00
Peter Steinberger
8b4cdbb21d refactor(infra): dedupe exec approval allowlist evaluation flow 2026-03-03 02:53:13 +00:00
Peter Steinberger
b8181e5944 refactor(gateway): dedupe agents server-method handlers 2026-03-03 02:53:13 +00:00
Peter Steinberger
7a8232187b refactor(config): dedupe session store save error handling 2026-03-03 02:53:13 +00:00
Peter Steinberger
1a0036283d refactor(security): dedupe telegram allowlist validation loops 2026-03-03 02:53:13 +00:00
Peter Steinberger
4fb6da2b32 refactor(tests): dedupe canvas host server setup 2026-03-03 02:53:13 +00:00
Peter Steinberger
4a59d0ad98 refactor(tests): dedupe session store route fixtures 2026-03-03 02:53:13 +00:00
Peter Steinberger
d068fc9f9d refactor(tests): dedupe agent handler test scaffolding 2026-03-03 02:53:13 +00:00
Peter Steinberger
369646a513 refactor(tests): dedupe openresponses http fixtures 2026-03-03 02:53:13 +00:00
Peter Steinberger
3460aa4dee refactor(browser): dedupe playwright interaction helpers 2026-03-03 02:53:13 +00:00
Peter Steinberger
e290f4ca41 refactor(config): dedupe repeated zod schema shapes 2026-03-03 02:53:13 +00:00
Peter Steinberger
884ca65dc7 refactor(acp): dedupe runtime option command plumbing 2026-03-03 02:53:13 +00:00
Peter Steinberger
1a52d943ed refactor(tests): dedupe model compat assertions 2026-03-03 02:53:13 +00:00
Peter Steinberger
7897ffb72f refactor(memory): dedupe openai batch fetch flows 2026-03-03 02:53:13 +00:00
Peter Steinberger
5c18ba6f65 refactor(tests): dedupe gateway chat history fixtures 2026-03-03 02:53:13 +00:00
Peter Steinberger
25a2fe2bea refactor(tests): dedupe control ui auth pairing fixtures 2026-03-03 02:53:13 +00:00
Peter Steinberger
fa4ff5f3d2 refactor(acp): extract install hint resolver 2026-03-03 02:51:24 +00:00
Peter Steinberger
ac318be405 refactor(voice-call): unify runtime cleanup lifecycle 2026-03-03 02:51:17 +00:00
Peter Steinberger
c85bd2646a refactor(cli): extract plugin install plan helper 2026-03-03 02:51:11 +00:00
Peter Steinberger
6472e03412 refactor(agents): share failover error matchers 2026-03-03 02:51:00 +00:00
Vincent Koc
24fd6c8278 CI: use Blacksmith docker builder in sandbox smoke 2026-03-02 18:48:18 -08:00
Vincent Koc
5cffbbda32 CI: use Blacksmith docker builder in install smoke 2026-03-02 18:48:18 -08:00
Vincent Koc
85d17fd429 CI: migrate docker release build cache to Blacksmith 2026-03-02 18:48:18 -08:00
Vincent Koc
96d56a9721 CI: enable sticky-disk pnpm cache on Linux CI jobs 2026-03-02 18:48:18 -08:00
Vincent Koc
ffd3ad032a CI: add sticky-disk mode to pnpm cache action 2026-03-02 18:48:18 -08:00
Vincent Koc
8a463af823 CI: add sticky-disk toggle to setup node action 2026-03-02 18:48:18 -08:00
Peter Steinberger
6bf1abf603 ci: use valid Blacksmith Windows runner label 2026-03-03 02:47:06 +00:00
Josh Lehman
3a8133d587 fix(scripts/pr): SSH-first prhead remote with GraphQL fallback for fork PRs (#32126)
Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-03-03 02:46:01 +00:00
Peter Steinberger
8ac924c769 refactor(security): centralize audit execution context 2026-03-03 02:42:43 +00:00
Peter Steinberger
2d033d2aa8 refactor(agents): split tool-result char estimator 2026-03-03 02:42:43 +00:00
Peter Steinberger
1ec9673cc5 refactor(telegram): split lane preview target helpers 2026-03-03 02:42:43 +00:00
Peter Steinberger
fdb0bf804f refactor(test): dedupe telegram draft-stream fixtures 2026-03-03 02:42:43 +00:00
Peter Steinberger
40f2e2b8a6 ci: scale Windows CI runner and test workers 2026-03-03 02:42:32 +00:00
Ayaan Zaidi
87977d7a19 fix: unblock build type errors 2026-03-03 08:11:51 +05:30
Peter Steinberger
9f691099db fix(voice-call): harden webhook lifecycle cleanup and retries (#32395) (thanks @scoootscooob) 2026-03-03 02:39:50 +00:00
scoootscooob
e707c97ca6 fix(voice-call): prevent EADDRINUSE by guarding webhook server lifecycle
Three issues caused the port to remain bound after partial failures:

1. VoiceCallWebhookServer.start() had no idempotency guard — calling it
   while the server was already listening would create a second server on
   the same port.

2. createVoiceCallRuntime() did not clean up the webhook server if a step
   after webhookServer.start() failed (e.g. manager.initialize). The
   server kept the port bound while the runtime promise rejected.

3. ensureRuntime() cached the rejected promise forever, so subsequent
   calls would re-throw the same error without ever retrying. Combined
   with (2), the port stayed orphaned until gateway restart.

Fixes #32387

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:39:50 +00:00
Peter Steinberger
0750fc2de1 test: consolidate extension runtime mocks and split bluebubbles webhook auth suite 2026-03-03 02:37:23 +00:00
Peter Steinberger
59567a8c5d ci: move changed-scope logic into tested script 2026-03-03 02:37:23 +00:00
Peter Steinberger
8ee357fc76 refactor: extract session init helpers 2026-03-03 02:37:23 +00:00
Peter Steinberger
9702d94196 refactor: split plugin runtime type contracts 2026-03-03 02:37:23 +00:00
AI南柯(KingMo)
30ab9b2068 fix(agents): recognize connection errors as retryable timeout failures (#31697)
* fix(agents): recognize connection errors as retryable timeout failures

## Problem

When a model endpoint becomes unreachable (e.g., local proxy down,
relay server offline), the failover system fails to switch to the
next candidate model. Errors like "Connection error." are not
classified as retryable, causing the session to hang on a broken
endpoint instead of falling back to healthy alternatives.

## Root Cause

Connection/network errors are not recognized by the current failover
classifier:
- Text patterns like "Connection error.", "fetch failed", "network error"
- Error codes like ECONNREFUSED, ENOTFOUND, EAI_AGAIN (in message text)

While `failover-error.ts` handles these as error codes (err.code),
it misses them when they appear as plain text in error messages.

## Solution

Extend timeout error patterns to include connection/network failures:

**In `errors.ts` (ERROR_PATTERNS.timeout):**
- Text: "connection error", "network error", "fetch failed", etc.
- Regex: /\beconn(?:refused|reset|aborted)\b/i, /\benotfound\b/i, /\beai_again\b/i

**In `failover-error.ts` (TIMEOUT_HINT_RE):**
- Same patterns for non-assistant error paths

## Testing

Added test cases covering:
- "Connection error."
- "fetch failed"
- "network error: ECONNREFUSED"
- "ENOTFOUND" / "EAI_AGAIN" in message text

## Impact

- **Compatibility:** High - only expands retryable error detection
- **Behavior:** Connection failures now trigger automatic fallback
- **Risk:** Low - changes are additive and well-tested

* style: fix code formatting for test file
2026-03-03 02:37:23 +00:00
riftzen-bit
5e1a2ea019 chore: remove unreachable "LINUX" from resolvePlatform return type
Address review feedback: since resolvePlatform() no longer returns
"LINUX", remove it from the union type to prevent future confusion.
2026-03-03 02:36:01 +00:00
riftzen-bit
008e4804a6 fix(gemini-cli-auth): use PLATFORM_UNSPECIFIED for Linux in loadCodeAssist
Google's loadCodeAssist API rejects "LINUX" as an invalid Platform enum
value, causing OAuth setup to fail with 400 Bad Request on Linux systems.

The pi-ai runtime already uses "PLATFORM_UNSPECIFIED" for this field.
This aligns the extension's discoverProject() with that approach by
returning "PLATFORM_UNSPECIFIED" for Linux (and other non-Windows/macOS
platforms) instead of "LINUX".

Also fixes the original resolvePlatform() which incorrectly fell through
to "MACOS" as default instead of explicitly checking for "darwin".
2026-03-03 02:36:01 +00:00
AaronWander
4c32411bee fix(exec): suggest increasing timeout on timeouts 2026-03-03 02:35:10 +00:00
Gustavo Madeira Santana
91cdb703bd Agents: add context metadata warmup retry backoff 2026-03-02 21:34:55 -05:00
john
04ac688dff fix(acp): use publishable acpx install hint 2026-03-03 02:34:07 +00:00
苏敏童0668001043
b29e913efe fix(docker): correct awk quoting in Docker GPG fingerprint check (#32153) 2026-03-03 02:32:46 +00:00
Peter Steinberger
895abc5a64 perf(security): allow audit snapshot and summary cache reuse 2026-03-03 02:32:13 +00:00
Peter Steinberger
62582fc088 perf(agents): cache per-pass context char estimates 2026-03-03 02:32:13 +00:00
Peter Steinberger
57336203d5 test(telegram): move preview-finalization cases to lane unit tests 2026-03-03 02:32:13 +00:00
Peter Steinberger
1929151103 refactor(telegram): extract sequential key module 2026-03-03 02:32:13 +00:00
Peter Steinberger
6ab9e00e17 fix: resolve pi-tools typing regressions 2026-03-03 02:27:59 +00:00
Peter Steinberger
2380c1b5fd refactor(ui): dedupe inline code wrap rules 2026-03-03 02:19:34 +00:00
Peter Steinberger
493b560dfd refactor(runtime): unify node version guard parsing 2026-03-03 02:19:34 +00:00
Peter Steinberger
1dd77e4106 refactor(slack): extract socket reconnect policy helpers 2026-03-03 02:19:34 +00:00
Peter Steinberger
4d52dfe85b refactor(sessions): add explicit merge activity policies 2026-03-03 02:19:34 +00:00
Peter Steinberger
d380ed710d refactor(agents): split pi-tools param and host-edit wrappers 2026-03-03 02:19:34 +00:00
Peter Steinberger
03755f8463 test(telegram): dedupe streaming cases and tighten sequential key checks 2026-03-03 02:14:15 +00:00
Peter Steinberger
7fdbf1202e test(security): reduce audit fixture setup overhead 2026-03-03 02:14:15 +00:00
Peter Steinberger
70db52de71 test(agents): centralize AgentMessage fixtures and remove unsafe casts 2026-03-03 02:14:15 +00:00
Gustavo Madeira Santana
15a0455d04 CLI: unify routed config positional parsing 2026-03-02 21:11:53 -05:00
Peter Steinberger
d3c637d193 fix: recover host edit success after post-write upstream throw (#32383) (thanks @polooooo) 2026-03-03 02:06:59 +00:00
倪汉杰0668001185
0fb3f188b2 fix(agents): only recover edit when oldText no longer in file (review feedback) 2026-03-03 02:06:59 +00:00
倪汉杰0668001185
bf6aa7ca67 fix(agents): treat host edit tool as success when file contains newText after upstream throw (fixes #32333) 2026-03-03 02:06:59 +00:00
Peter Steinberger
0fd77c9856 refactor: modularize plugin runtime and test hooks 2026-03-03 02:06:58 +00:00
Peter Steinberger
f77f1d3800 fix: preserve inline code copy fidelity in web ui (#32346) (thanks @hclsys) 2026-03-03 02:05:45 +00:00
HCL
7c90ef7c52 fix(webui): prevent inline code from breaking mid-token on copy/paste
The parent `.chat-text` applies `overflow-wrap: anywhere; word-break: break-word;`
which forces long tokens (UUIDs, hashes) inside inline `<code>` to break across
visual lines. When copied, the browser injects spaces at those break points,
corrupting the pasted value.

Override with `overflow-wrap: normal; word-break: keep-all;` on inline `<code>`
selectors so tokens stay intact.

Fixes #32230

Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-03 02:05:37 +00:00
391 changed files with 25073 additions and 8243 deletions

View File

@@ -51,6 +51,10 @@ vendor/
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
!apps/shared/
!apps/shared/OpenClawKit/
!apps/shared/OpenClawKit/Sources/
!apps/shared/OpenClawKit/Sources/OpenClawKit/
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
!apps/shared/OpenClawKit/Tools/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**

View File

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

View File

@@ -15,6 +15,10 @@ inputs:
description: Whether to install Bun alongside Node.
required: false
default: "true"
use-sticky-disk:
description: Use Blacksmith sticky disks for pnpm store caching.
required: false
default: "false"
frozen-lockfile:
description: Whether to use --frozen-lockfile for install.
required: false
@@ -47,6 +51,7 @@ runs:
with:
pnpm-version: ${{ inputs.pnpm-version }}
cache-key-suffix: "node22"
use-sticky-disk: ${{ inputs.use-sticky-disk }}
- name: Setup Bun
if: inputs.install-bun == 'true'

View File

@@ -9,6 +9,14 @@ inputs:
description: Suffix appended to the cache key.
required: false
default: "node22"
use-sticky-disk:
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store.
required: false
default: "false"
use-restore-keys:
description: Whether to use restore-keys fallback for actions/cache.
required: false
default: "true"
runs:
using: composite
steps:
@@ -38,7 +46,22 @@ runs:
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
- name: Mount pnpm store sticky disk
if: inputs.use-sticky-disk == 'true'
uses: useblacksmith/stickydisk@v1
with:
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }}
path: ${{ steps.pnpm-store.outputs.path }}
- name: Restore pnpm store cache (exact key only)
if: inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Restore pnpm store cache (with fallback keys)
if: inputs.use-sticky-disk != 'true' && inputs.use-restore-keys == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}

View File

@@ -38,6 +38,7 @@ jobs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
run_android: ${{ steps.scope.outputs.run_android }}
run_windows: ${{ steps.scope.outputs.run_windows }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -57,71 +58,7 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
fi
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
# Fail-safe: run broad checks if detection fails.
echo "run_node=true" >> "$GITHUB_OUTPUT"
echo "run_macos=true" >> "$GITHUB_OUTPUT"
echo "run_android=true" >> "$GITHUB_OUTPUT"
exit 0
fi
run_node=false
run_macos=false
run_android=false
has_non_docs=false
has_non_native_non_docs=false
while IFS= read -r path; do
[ -z "$path" ] && continue
case "$path" in
docs/*|*.md|*.mdx)
continue
;;
*)
has_non_docs=true
;;
esac
case "$path" in
# Generated protocol models are already covered by protocol:check and
# should not force the full native macOS lane.
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
;;
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
run_macos=true
;;
esac
case "$path" in
apps/android/*|apps/shared/*)
run_android=true
;;
esac
case "$path" in
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
run_node=true
;;
esac
case "$path" in
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
;;
*)
has_non_native_non_docs=true
;;
esac
done <<< "$CHANGED"
# If there are non-doc files outside native app trees, keep Node checks enabled.
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
run_node=true
fi
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
@@ -138,6 +75,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Build dist
run: pnpm build
@@ -164,6 +102,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Download dist artifact
uses: actions/download-artifact@v4
@@ -207,6 +146,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "${{ matrix.runtime == 'bun' }}"
use-sticky-disk: "true"
- name: Configure Node test resources
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
@@ -223,8 +163,8 @@ jobs:
# Types, lint, and format check.
check:
name: "check"
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -236,6 +176,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Check types and lint and oxfmt
run: pnpm check
@@ -275,6 +216,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Run ${{ matrix.tool }} dead-code scan
run: ${{ matrix.command }}
@@ -300,6 +242,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Check docs
run: pnpm check:docs
@@ -342,6 +285,7 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "true"
- name: Setup Python
uses: actions/setup-python@v5
@@ -386,14 +330,14 @@ jobs:
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-windows-2025
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true')
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 45
env:
NODE_OPTIONS: --max-old-space-size=4096
# Keep total concurrency predictable on the 16 vCPU runner:
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
OPENCLAW_TEST_WORKERS: 2
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the 16 vCPU runner.
# Windows shard 2 has shown intermittent instability at 2 workers.
OPENCLAW_TEST_WORKERS: 1
defaults:
run:
shell: bash
@@ -401,26 +345,26 @@ jobs:
fail-fast: false
matrix:
include:
- runtime: node
task: lint
shard_index: 0
shard_count: 1
command: pnpm lint
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
shard_count: 4
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
shard_count: 4
command: pnpm test
- runtime: node
task: protocol
shard_index: 0
shard_count: 1
command: pnpm protocol:check
task: test
shard_index: 3
shard_count: 4
command: pnpm test
- runtime: node
task: test
shard_index: 4
shard_count: 4
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -446,31 +390,18 @@ jobs:
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Download dist artifact (lint lane)
if: matrix.task == 'lint'
uses: actions/download-artifact@v4
with:
name: dist-build
path: dist/
- name: Verify dist artifact (lint lane)
if: matrix.task == 'lint'
run: |
set -euo pipefail
test -s dist/index.js
test -s dist/plugin-sdk/index.js
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
check-latest: true
check-latest: false
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
use-restore-keys: "false"
- name: Runtime versions
run: |
@@ -497,9 +428,17 @@ jobs:
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Build A2UI bundle (Windows)
if: matrix.task == 'test'
run: pnpm canvas:a2ui:bundle
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
- name: Prune pnpm store (Windows cache hygiene)
if: always()
run: pnpm store prune || true
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job

View File

@@ -34,8 +34,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -92,14 +92,12 @@ jobs:
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v2
with:
context: .
platforms: linux/amd64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
provenance: false
push: true
@@ -115,8 +113,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -173,14 +171,12 @@ jobs:
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v2
with:
context: .
platforms: linux/arm64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
provenance: false
push: true

View File

@@ -44,10 +44,14 @@ jobs:
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
use-sticky-disk: "true"
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
- name: Run root Dockerfile CLI smoke
run: |
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .

View File

@@ -26,6 +26,9 @@ jobs:
with:
submodules: false
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
- name: Build minimal sandbox base (USER sandbox)
shell: bash
run: |

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ mise.toml
apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
apps/android/.kotlin/
# Bun build artifacts
*.bun-build

View File

@@ -6,202 +6,209 @@ Docs: https://docs.openclaw.ai
### Changes
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
- Docs/Feishu webhook setup: clarify `verificationToken` configuration with Open Platform navigation steps, and align Feishu sender-allowlist guidance plus zh-CN channel documentation with current runtime behavior. (#31555)
- Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, `openclaw secrets` planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
### Breaking
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
### Fixes
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
- Voice-call/runtime lifecycle: prevent `EADDRINUSE` loops by resetting failed runtime promises, making webhook `start()` idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
- Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
- Slack/session routing: keep top-level channel messages in one shared session when `replyToMode=off`, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
- Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
- Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
- Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
- Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
- Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (`newText` present and `oldText` absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
## 2026.3.1

View File

@@ -72,7 +72,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
exit 1; \

View File

@@ -120,7 +120,8 @@ actor CameraController {
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError) { output in
mapSetupError: Self.mapMovieSetupError,
operation: { output in
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont)
@@ -131,7 +132,7 @@ actor CameraController {
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
return try Data(contentsOf: mp4URL)
}
})
return (
format: format.rawValue,
base64: data.base64EncodedString(),

View File

@@ -21,7 +21,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
self.manager
}
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? {
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
get { self.locationContinuation }
set { self.locationContinuation = newValue }
}
@@ -65,9 +65,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs,
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
request: { try await self.requestLocationOnce() },
withTimeout: { timeoutMs, operation in
try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
}
})
}
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {

View File

@@ -302,6 +302,7 @@ private struct ManualEntryStep: View {
// (GatewaySetupCode) decode raw setup codes.
}
@MainActor
private func gatewayConnectionStatusLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
@@ -309,6 +310,7 @@ private func gatewayConnectionStatusLines(
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
}
@MainActor
private func resetGatewayConnectionState(
appModel: NodeAppModel,
connectStatusText: inout String?,
@@ -319,6 +321,7 @@ private func resetGatewayConnectionState(
connectingGatewayID = nil
}
@MainActor
@ViewBuilder
private func gatewayConnectionStatusSection(
appModel: NodeAppModel,

View File

@@ -1,4 +1,5 @@
import AVFoundation
import OpenClawKit
import ReplayKit
final class ScreenRecordService: @unchecked Sendable {

View File

@@ -441,13 +441,6 @@ final class VoiceWakeManager: NSObject {
}
}
private static func permissionMessage(
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
self.deniedByDefaultPermissionMessage(kind: kind, isUndetermined: status == .undetermined)
}
private static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String
@@ -466,7 +459,7 @@ final class VoiceWakeManager: NSObject {
}
}
private static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
if isUndetermined {
return "\(kind) permission not granted"
}

View File

@@ -164,7 +164,7 @@ actor CameraCaptureService {
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}

View File

@@ -9,5 +9,4 @@ final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
queueLabel: "ai.openclaw.canvaswatcher",
onChange: onChange))
}
}

View File

@@ -25,11 +25,22 @@ extension CanvasWindowController {
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
LoopbackHost.parseIPv4(host)
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
LoopbackHost.isLocalNetworkIPv4(ip)
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {

View File

@@ -30,5 +30,4 @@ final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
},
onChange: onChange))
}
}

View File

@@ -124,12 +124,12 @@ enum ExecSystemRunCommandValidator {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true

View File

@@ -39,11 +39,12 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, Locatio
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs,
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
request: { try await self.requestLocationOnce() },
withTimeout: { timeoutMs, operation in
try await self.withTimeout(timeoutMs: timeoutMs) {
try await operation()
}
}
})
}
private func withTimeout<T: Sendable>(

View File

@@ -184,9 +184,9 @@ struct NodeMenuRowView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
.foregroundStyle(self.palette.primary)
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
.foregroundStyle(self.palette.primary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
@@ -195,9 +195,9 @@ struct NodeMenuRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(2)

View File

@@ -64,9 +64,10 @@ final class NotifyOverlayController {
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
target: target) { window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
target: target)
{ window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
}

View File

@@ -311,7 +311,7 @@ extension OnboardingView {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)

View File

@@ -134,7 +134,8 @@ enum PairingAlertSupport {
messageText: String,
informativeText: String,
alertHostWindow: inout NSWindow?,
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert {
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
{
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
@@ -164,32 +165,6 @@ enum PairingAlertSupport {
completion: completion)
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
messageText: String,
informativeText: String,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
alertHostWindow: inout NSWindow?,
clearActive: @escaping @MainActor (NSWindow) -> Void,
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &activeAlert,
activeRequestId: &activeRequestId,
alertHostWindow: &alertHostWindow)
{ response, hostWindow in
Task { @MainActor in
clearActive(hostWindow)
await onResponse(response, request)
}
}
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
@@ -199,19 +174,18 @@ enum PairingAlertSupport {
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
request: request,
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
alertHostWindow: &state.alertHostWindow)
{ response, hostWindow in
Task { @MainActor in
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
await onResponse(response, request)
}
}
alertHostWindow: &state.alertHostWindow,
completion: { response, hostWindow in
Task { @MainActor in
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
await onResponse(response, request)
}
})
}
static func clearActivePairingAlert(
@@ -231,12 +205,12 @@ enum PairingAlertSupport {
hostWindow: hostWindow)
}
static func stopPairingPrompter<Request>(
static func stopPairingPrompter(
isStopping: inout Bool,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
task: inout Task<Void, Never>?,
queue: inout [Request],
queue: inout [some Any],
isPresenting: inout Bool,
alertHostWindow: inout NSWindow?)
{
@@ -252,10 +226,10 @@ enum PairingAlertSupport {
alertHostWindow = nil
}
static func stopPairingPrompter<Request>(
static func stopPairingPrompter(
isStopping: inout Bool,
task: inout Task<Void, Never>?,
queue: inout [Request],
queue: inout [some Any],
isPresenting: inout Bool,
state: PairingAlertState)
{

View File

@@ -36,6 +36,7 @@ final class PeekabooBridgeHostCoordinator {
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return Self.legacySocketDirectoryNames.map { Self.makeSocketPath(for: $0, in: base) }
}
func setEnabled(_ enabled: Bool) async {
if enabled {
await self.startIfNeeded()
@@ -85,7 +86,7 @@ final class PeekabooBridgeHostCoordinator {
}
private func ensureLegacySocketSymlinks() {
Self.legacySocketPaths.forEach { legacyPath in
for legacyPath in Self.legacySocketPaths {
self.ensureLegacySocketSymlink(at: legacyPath)
}
}
@@ -116,7 +117,9 @@ final class PeekabooBridgeHostCoordinator {
}
try fileManager.createSymbolicLink(atPath: legacyPath, withDestinationPath: Self.openclawSocketPath)
} catch {
self.logger.debug("Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription, privacy: .public)")
let message = "Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription)"
self.logger
.debug("\(message, privacy: .public)")
}
}

View File

@@ -33,9 +33,10 @@ final class TalkOverlayController {
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
target: target) { window in
window.setFrame(target, display: true)
window.orderFrontRegardless()
target: target)
{ window in
window.setFrame(target, display: true)
window.orderFrontRegardless()
}
}

View File

@@ -77,14 +77,14 @@ struct GatewayCostUsageDay: Codable {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.date = try c.decode(String.self, forKey: .date)
self.totals = GatewayCostUsageTotals(
input: try c.decode(Int.self, forKey: .input),
output: try c.decode(Int.self, forKey: .output),
cacheRead: try c.decode(Int.self, forKey: .cacheRead),
cacheWrite: try c.decode(Int.self, forKey: .cacheWrite),
totalTokens: try c.decode(Int.self, forKey: .totalTokens),
totalCost: try c.decode(Double.self, forKey: .totalCost),
missingCostEntries: try c.decode(Int.self, forKey: .missingCostEntries))
self.totals = try GatewayCostUsageTotals(
input: c.decode(Int.self, forKey: .input),
output: c.decode(Int.self, forKey: .output),
cacheRead: c.decode(Int.self, forKey: .cacheRead),
cacheWrite: c.decode(Int.self, forKey: .cacheWrite),
totalTokens: c.decode(Int.self, forKey: .totalTokens),
totalCost: c.decode(Double.self, forKey: .totalCost),
missingCostEntries: c.decode(Int.self, forKey: .missingCostEntries))
}
func encode(to encoder: Encoder) throws {

View File

@@ -172,9 +172,9 @@ actor VoicePushToTalk {
let adoptedPrefix = self.adoptedPrefix
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : VoiceOverlayTextFormatting
.makeAttributed(
committed: adoptedPrefix,
volatile: "",
isFinal: false)
committed: adoptedPrefix,
volatile: "",
isFinal: false)
self.overlayToken = await MainActor.run {
VoiceSessionCoordinator.shared.startSession(
source: .pushToTalk,
@@ -406,5 +406,4 @@ actor VoicePushToTalk {
if suffix.isEmpty { return prefix }
return "\(prefix) \(suffix)"
}
}

View File

@@ -23,10 +23,11 @@ extension VoiceWakeOverlayController {
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
// Keep the status item in listening mode until we explicitly dismiss the overlay.
AppStateStore.shared.triggerVoiceEars(ttl: nil)
}) { window in
},
onAlreadyVisible: { window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
})
}
private func ensureWindow() {

View File

@@ -773,5 +773,4 @@ actor VoiceWakeRuntime {
}
#endif
}

View File

@@ -13,8 +13,8 @@ public enum TailscaleNetwork {
}
public static func detectTailnetIPv4() -> String? {
for entry in NetworkInterfaceIPv4.addresses() {
if self.isTailnetIPv4(entry.ip) { return entry.ip }
for entry in NetworkInterfaceIPv4.addresses() where self.isTailnetIPv4(entry.ip) {
return entry.ip
}
return nil
}

View File

@@ -1030,6 +1030,74 @@ public struct PushTestResult: Codable, Sendable {
}
}
public struct SecretsReloadParams: Codable, Sendable {}
public struct SecretsResolveParams: Codable, Sendable {
public let commandname: String
public let targetids: [String]
public init(
commandname: String,
targetids: [String])
{
self.commandname = commandname
self.targetids = targetids
}
private enum CodingKeys: String, CodingKey {
case commandname = "commandName"
case targetids = "targetIds"
}
}
public struct SecretsResolveAssignment: Codable, Sendable {
public let path: String?
public let pathsegments: [String]
public let value: AnyCodable
public init(
path: String?,
pathsegments: [String],
value: AnyCodable)
{
self.path = path
self.pathsegments = pathsegments
self.value = value
}
private enum CodingKeys: String, CodingKey {
case path
case pathsegments = "pathSegments"
case value
}
}
public struct SecretsResolveResult: Codable, Sendable {
public let ok: Bool?
public let assignments: [SecretsResolveAssignment]?
public let diagnostics: [String]?
public let inactiverefpaths: [String]?
public init(
ok: Bool?,
assignments: [SecretsResolveAssignment]?,
diagnostics: [String]?,
inactiverefpaths: [String]?)
{
self.ok = ok
self.assignments = assignments
self.diagnostics = diagnostics
self.inactiverefpaths = inactiverefpaths
}
private enum CodingKeys: String, CodingKey {
case ok
case assignments
case diagnostics
case inactiverefpaths = "inactiveRefPaths"
}
}
public struct SessionsListParams: Codable, Sendable {
public let limit: Int?
public let activeminutes: Int?

View File

@@ -1030,6 +1030,74 @@ public struct PushTestResult: Codable, Sendable {
}
}
public struct SecretsReloadParams: Codable, Sendable {}
public struct SecretsResolveParams: Codable, Sendable {
public let commandname: String
public let targetids: [String]
public init(
commandname: String,
targetids: [String])
{
self.commandname = commandname
self.targetids = targetids
}
private enum CodingKeys: String, CodingKey {
case commandname = "commandName"
case targetids = "targetIds"
}
}
public struct SecretsResolveAssignment: Codable, Sendable {
public let path: String?
public let pathsegments: [String]
public let value: AnyCodable
public init(
path: String?,
pathsegments: [String],
value: AnyCodable)
{
self.path = path
self.pathsegments = pathsegments
self.value = value
}
private enum CodingKeys: String, CodingKey {
case path
case pathsegments = "pathSegments"
case value
}
}
public struct SecretsResolveResult: Codable, Sendable {
public let ok: Bool?
public let assignments: [SecretsResolveAssignment]?
public let diagnostics: [String]?
public let inactiverefpaths: [String]?
public init(
ok: Bool?,
assignments: [SecretsResolveAssignment]?,
diagnostics: [String]?,
inactiverefpaths: [String]?)
{
self.ok = ok
self.assignments = assignments
self.diagnostics = diagnostics
self.inactiverefpaths = inactiverefpaths
}
private enum CodingKeys: String, CodingKey {
case ok
case assignments
case diagnostics
case inactiverefpaths = "inactiveRefPaths"
}
}
public struct SessionsListParams: Codable, Sendable {
public let limit: Int?
public let activeminutes: Int?

View File

@@ -197,6 +197,17 @@ Edit `~/.openclaw/openclaw.json`:
If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
#### Verification Token (webhook mode)
When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value:
1. In Feishu Open Platform, open your app
2. Go to **Development****Events & Callbacks** (开发配置 → 事件与回调)
3. Open the **Encryption** tab (加密策略)
4. Copy **Verification Token**
![Verification Token location](../images/feishu-verification-token.png)
### Configure via environment variables
```bash
@@ -359,9 +370,9 @@ After approval, you can chat normally.
}
```
### Allow specific users to run control commands in a group (e.g. /reset, /new)
### Restrict which senders can message in a group (sender allowlist)
In addition to allowing the group itself, control commands are gated by the **sender** open_id.
In addition to allowing the group itself, **all messages** in that group are gated by the sender open_id: only users listed in `groups.<chat_id>.allowFrom` have their messages processed; messages from other members are ignored (this is full sender-level gating, not only for control commands like /reset or /new).
```json5
{

View File

@@ -13,20 +13,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
## Job Overview
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Non-docs changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
| Job | Purpose | When it runs |
| ----------------- | ------------------------------------------------------- | ------------------------------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
## Fail-Fast Order
@@ -36,12 +36,14 @@ Jobs are ordered so cheap checks fail before expensive ones run:
2. `build-artifacts` (blocked on above)
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build)
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
## Runners
| Runner | Jobs |
| -------------------------------- | ------------------------------------------ |
| `blacksmith-16vcpu-ubuntu-2404` | Most Linux jobs, including scope detection |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-32vcpu-windows-2025` | `checks-windows` |
| `macos-latest` | `macos`, `ios` |
## Local Equivalents

View File

@@ -50,3 +50,5 @@ Notes:
- `memory status --deep --index` runs a reindex if the store is dirty.
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.

View File

@@ -34,6 +34,9 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
## Notes
- `--token` and `--password` are mutually exclusive.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local `gateway.auth.password` SecretRefs are resolved when password auth can win (explicit `gateway.auth.mode="password"` or inferred password mode with no winning token from auth/env), and no CLI auth override is passed.
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
- After scanning, approve device pairing with:
- `openclaw devices list`
- `openclaw devices approve <requestId>`

View File

@@ -9,14 +9,14 @@ title: "secrets"
# `openclaw secrets`
Use `openclaw secrets` to migrate credentials from plaintext to SecretRefs and keep the active secrets runtime healthy.
Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot healthy.
Command roles:
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
- `audit`: read-only scan of config + auth stores + legacy residues (`.env`, `auth.json`) for plaintext, unresolved refs, and precedence drift.
- `configure`: interactive planner for provider setup + target mapping + preflight (TTY required).
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub migrated plaintext residues.
- `audit`: read-only scan of configuration/auth stores and legacy residues for plaintext, unresolved refs, and precedence drift.
- `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required).
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues.
Recommended operator loop:
@@ -31,11 +31,13 @@ openclaw secrets reload
Exit code note for CI/gates:
- `audit --check` returns `1` on findings, `2` when refs are unresolved.
- `audit --check` returns `1` on findings.
- unresolved refs return `2`.
Related:
- Secrets guide: [Secrets Management](/gateway/secrets)
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
- Security guide: [Security](/gateway/security)
## Reload runtime snapshot
@@ -59,8 +61,8 @@ Scan OpenClaw state for:
- plaintext secret storage
- unresolved refs
- precedence drift (`auth-profiles` shadowing config refs)
- legacy residues (`auth.json`, OAuth out-of-scope notes)
- precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs)
- legacy residues (legacy auth store entries, OAuth reminders)
```bash
openclaw secrets audit
@@ -71,7 +73,7 @@ openclaw secrets audit --json
Exit behavior:
- `--check` exits non-zero on findings.
- unresolved refs exit with a higher-priority non-zero code.
- unresolved refs exit with higher-priority non-zero code.
Report shape highlights:
@@ -85,7 +87,7 @@ Report shape highlights:
## Configure (interactive helper)
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
Build provider and SecretRef changes interactively, run preflight, and optionally apply:
```bash
openclaw secrets configure
@@ -93,6 +95,7 @@ openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
openclaw secrets configure --apply --yes
openclaw secrets configure --providers-only
openclaw secrets configure --skip-provider-setup
openclaw secrets configure --agent ops
openclaw secrets configure --json
```
@@ -106,23 +109,26 @@ Flags:
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
- `--agent <id>`: scope `auth-profiles.json` target discovery and writes to one agent store.
Notes:
- Requires an interactive TTY.
- You cannot combine `--providers-only` with `--skip-provider-setup`.
- `configure` targets secret-bearing fields in `openclaw.json`.
- Include all secret-bearing fields you intend to migrate (for example both `models.providers.*.apiKey` and `skills.entries.*.apiKey`) so audit can reach a clean state.
- `configure` targets secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for the selected agent scope.
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
- It performs preflight resolution before apply.
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
- Apply path is one-way for migrated plaintext values.
- Apply path is one-way for scrubbed plaintext values.
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
- With `--apply` (and no `--yes`), CLI prompts an extra irreversible-migration confirmation.
- With `--apply` (and no `--yes`), CLI prompts an extra irreversible confirmation.
Exec provider safety note:
- Homebrew installs often expose symlinked binaries under `/opt/homebrew/bin/*`.
- Set `allowSymlinkCommand: true` only when needed for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
- On Windows, if ACL verification is unavailable for a provider path, OpenClaw fails closed. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
## Apply a saved plan
@@ -154,10 +160,9 @@ Safety comes from strict preflight + atomic-ish apply with best-effort in-memory
## Example
```bash
# Audit first, then configure, then confirm clean:
openclaw secrets audit --check
openclaw secrets configure
openclaw secrets audit --check
```
If `audit --check` still reports plaintext findings after a partial migration, verify you also migrated skill keys (`skills.entries.*.apiKey`) and any other reported target paths.
If `audit --check` still reports plaintext findings, update the remaining reported target paths and rerun audit.

View File

@@ -1321,6 +1321,7 @@
"pages": [
"reference/wizard",
"reference/token-use",
"reference/secretref-credential-surface",
"reference/prompt-caching",
"reference/api-usage-costs",
"reference/transcript-hygiene",

View File

@@ -1170,8 +1170,8 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
**`docker.binds`** mounts additional host directories; global and per-agent binds are merged.
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config.
noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL that serves a local bootstrap page; noVNC password is passed via URL fragment (instead of URL query).
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in `openclaw.json`.
noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL (instead of exposing the password in the shared URL).
- `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
@@ -1605,7 +1605,8 @@ Defaults for Talk mode (macOS/iOS/Android).
```
- Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`.
- `apiKey` falls back to `ELEVENLABS_API_KEY`.
- `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects.
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
- `voiceAliases` lets Talk directives use friendly names.
---
@@ -1804,7 +1805,7 @@ Configures inbound media understanding (image/audio/video):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc.)
- `model`: model id override
- `profile` / `preferredProfile`: auth profile selection
- `profile` / `preferredProfile`: `auth-profiles.json` profile selection
**CLI entry** (`type: "cli"`):
@@ -1817,7 +1818,7 @@ Configures inbound media understanding (image/audio/video):
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
- Failures fall back to the next entry.
Provider auth follows standard order: auth profiles → env vars → `models.providers.*.apiKey`.
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
</Accordion>
@@ -2638,14 +2639,11 @@ Validation:
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
### Supported fields in config
### Supported credential surface
- `models.providers.<provider>.apiKey`
- `skills.entries.<skillKey>.apiKey`
- `channels.googlechat.serviceAccount`
- `channels.googlechat.serviceAccountRef`
- `channels.googlechat.accounts.<accountId>.serviceAccount`
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
- Canonical matrix: [SecretRef Credential Surface](/reference/secretref-credential-surface)
- `secrets apply` targets supported `openclaw.json` credential paths.
- `auth-profiles.json` refs are included in runtime resolution and audit coverage.
### Secret providers config
@@ -2683,6 +2681,7 @@ Notes:
- If `trustedDirs` is configured, the trusted-dir check applies to the resolved target path.
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
- Active-surface filtering applies during activation: unresolved refs on enabled surfaces fail startup/reload, while inactive surfaces are skipped with diagnostics.
---
@@ -2702,8 +2701,8 @@ Notes:
}
```
- Per-agent auth profiles stored at `<agentDir>/auth-profiles.json`.
- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
- Per-agent profiles are stored at `<agentDir>/auth-profiles.json`.
- `auth-profiles.json` supports value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
- Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered.
- Legacy OAuth imports from `~/.openclaw/credentials/oauth.json`.
- See [OAuth](/concepts/oauth).
@@ -2900,7 +2899,7 @@ Split config into multiple files:
- Array of files: deep-merged in order (later overrides earlier).
- Sibling keys: merged after includes (override included values).
- Nested includes: up to 10 levels deep.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of the main config file). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
- Errors: clear messages for missing files, parse errors, and circular includes.
---

View File

@@ -532,6 +532,7 @@ Rules:
```
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets).
Supported credential paths are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface).
</Accordion>
See [Environment](/help/environment) for full precedence and sources.

View File

@@ -1,9 +1,9 @@
---
summary: "Contract for `secrets apply` plans: allowed target paths, validation, and ref-only auth-profile behavior"
summary: "Contract for `secrets apply` plans: target validation, path matching, and `auth-profiles.json` target scope"
read_when:
- Generating or reviewing `openclaw secrets apply` plan files
- Generating or reviewing `openclaw secrets apply` plans
- Debugging `Invalid plan target path` errors
- Understanding how `keyRef` and `tokenRef` influence implicit provider discovery
- Understanding target type and path validation behavior
title: "Secrets Apply Plan Contract"
---
@@ -11,7 +11,7 @@ title: "Secrets Apply Plan Contract"
This page defines the strict contract enforced by `openclaw secrets apply`.
If a target does not match these rules, apply fails before mutating config.
If a target does not match these rules, apply fails before mutating configuration.
## Plan file shape
@@ -29,29 +29,47 @@ If a target does not match these rules, apply fails before mutating config.
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId: "main",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
}
```
## Allowed target types and paths
## Supported target scope
| `target.type` | Allowed `target.path` shape | Optional id match rule |
| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- |
| `models.providers.apiKey` | `models.providers.<providerId>.apiKey` | `providerId` must match `<providerId>` when present |
| `skills.entries.apiKey` | `skills.entries.<skillKey>.apiKey` | n/a |
| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted |
| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts.<accountId>.serviceAccount` | `accountId` must match `<accountId>` when present |
Plan targets are accepted for supported credential paths in:
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
## Target type behavior
General rule:
- `target.type` must be recognized and must match the normalized `target.path` shape.
Compatibility aliases remain accepted for existing plans:
- `models.providers.apiKey`
- `skills.entries.apiKey`
- `channels.googlechat.serviceAccount`
## Path validation rules
Each target is validated with all of the following:
- `type` must be one of the allowed target types above.
- `type` must be a recognized target type.
- `path` must be a non-empty dot path.
- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`.
- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`.
- The normalized path must match one of the allowed path shapes for the target type.
- If `providerId` / `accountId` is set, it must match the id encoded in the path.
- The normalized path must match the registered path shape for the target type.
- If `providerId` or `accountId` is set, it must match the id encoded in the path.
- `auth-profiles.json` targets require `agentId`.
- When creating a new `auth-profiles.json` mapping, include `authProfileProvider`.
## Failure behavior
@@ -61,19 +79,12 @@ If a target fails validation, apply exits with an error like:
Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl
```
No partial mutation is committed for that invalid target path.
No writes are committed for an invalid plan.
## Ref-only auth profiles and implicit providers
## Runtime and audit scope notes
Implicit provider discovery also considers auth profiles that store refs instead of plaintext credentials:
- `type: "api_key"` profiles can use `keyRef` (for example env-backed refs).
- `type: "token"` profiles can use `tokenRef`.
Behavior:
- For API-key providers (for example `volcengine`, `byteplus`), ref-only profiles can still activate implicit provider entries.
- For `github-copilot`, if the profile has no plaintext token, discovery will try `tokenRef` env resolution before token exchange.
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
- `secrets apply` writes supported `openclaw.json` targets, supported `auth-profiles.json` targets, and optional scrub targets.
## Operator checks
@@ -85,10 +96,11 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
```
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to one of the allowed shapes above.
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.
## Related docs
- [Secrets Management](/gateway/secrets)
- [CLI `secrets`](/cli/secrets)
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
- [Configuration Reference](/gateway/configuration-reference)

View File

@@ -1,35 +1,70 @@
---
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing"
read_when:
- Configuring SecretRefs for providers, auth profiles, skills, or Google Chat
- Operating secrets reload/audit/configure/apply safely in production
- Understanding fail-fast and last-known-good behavior
- Configuring SecretRefs for provider credentials and `auth-profiles.json` refs
- Operating secrets reload, audit, configure, and apply safely in production
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
title: "Secrets Management"
---
# Secrets management
OpenClaw supports additive secret references so credentials do not need to be stored as plaintext in config files.
OpenClaw supports additive SecretRefs so supported credentials do not need to be stored as plaintext in configuration.
Plaintext still works. Secret refs are optional.
Plaintext still works. SecretRefs are opt-in per credential.
## Goals and runtime model
Secrets are resolved into an in-memory runtime snapshot.
- Resolution is eager during activation, not lazy on request paths.
- Startup fails fast if any referenced credential cannot be resolved.
- Reload uses atomic swap: full success or keep last-known-good.
- Runtime requests read from the active in-memory snapshot.
- Startup fails fast when an effectively active SecretRef cannot be resolved.
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
- Runtime requests read from the active in-memory snapshot only.
This keeps secret-provider outages off the hot request path.
This keeps secret-provider outages off hot request paths.
## Active-surface filtering
SecretRefs are validated only on effectively active surfaces.
- Enabled surfaces: unresolved refs block startup/reload.
- Inactive surfaces: unresolved refs do not block startup/reload.
- Inactive refs emit non-fatal diagnostics with code `SECRETS_REF_IGNORED_INACTIVE_SURFACE`.
Examples of inactive surfaces:
- Disabled channel/account entries.
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
## Gateway auth surface diagnostics
When a SecretRef is configured on `gateway.auth.password`, `gateway.remote.token`, or
`gateway.remote.password`, gateway startup/reload logs the surface state explicitly:
- `active`: the SecretRef is part of the effective auth surface and must resolve.
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or
because remote auth is disabled/not active.
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the
active-surface policy, so you can see why a credential was treated as active or inactive.
## Onboarding reference preflight
When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving:
When onboarding runs in interactive mode and you choose SecretRef storage, OpenClaw runs preflight validation before saving:
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
- Provider refs (`file` or `exec`): validates the selected provider, resolves the provided `id`, and checks value type.
- Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks resolved value type.
If validation fails, onboarding shows the error and lets you retry.
@@ -122,22 +157,24 @@ Define providers under `secrets.providers`:
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
- Path must pass ownership/permission checks.
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
### Exec provider
- Runs configured absolute binary path, no shell.
- By default, `command` must point to a regular file (not a symlink).
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
- Enable `allowSymlinkCommand` only when required for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
- When `trustedDirs` is set, checks apply to the resolved target path.
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
- Request payload (stdin):
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
Request payload (stdin):
```json
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
```
- Response payload (stdout):
Response payload (stdout):
```json
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } }
@@ -242,37 +279,33 @@ Optional per-id errors:
}
```
## In-scope fields (v1)
## Supported credential surface
### `~/.openclaw/openclaw.json`
Canonical supported and unsupported credentials are listed in:
- `models.providers.<provider>.apiKey`
- `skills.entries.<skillKey>.apiKey`
- `channels.googlechat.serviceAccount`
- `channels.googlechat.serviceAccountRef`
- `channels.googlechat.accounts.<accountId>.serviceAccount`
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
### `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- `profiles.<profileId>.keyRef` for `type: "api_key"`
- `profiles.<profileId>.tokenRef` for `type: "token"`
OAuth credential storage changes are out of scope.
Runtime-minted or rotating credentials and OAuth refresh material are intentionally excluded from read-only SecretRef resolution.
## Required behavior and precedence
- Field without ref: unchanged.
- Field with ref: required at activation time.
- If plaintext and ref both exist, ref wins at runtime and plaintext is ignored.
- Field without a ref: unchanged.
- Field with a ref: required on active surfaces during activation.
- If both plaintext and ref are present, ref takes precedence on supported precedence paths.
Warning code:
Warning and audit signals:
- `SECRETS_REF_OVERRIDES_PLAINTEXT`
- `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning)
- `REF_SHADOWED` (audit finding when `auth-profiles.json` credentials take precedence over `openclaw.json` refs)
Google Chat compatibility behavior:
- `serviceAccountRef` takes precedence over plaintext `serviceAccount`.
- Plaintext value is ignored when sibling ref is set.
## Activation triggers
Secret activation is attempted on:
Secret activation runs on:
- Startup (preflight plus final activation)
- Config reload hot-apply path
@@ -283,9 +316,9 @@ Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- Runtime reload failure keeps last-known-good snapshot.
- Runtime reload failure keeps the last-known-good snapshot.
## Degraded and recovered operator signals
## Degraded and recovered signals
When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state.
@@ -297,13 +330,22 @@ One-shot system event and log codes:
Behavior:
- Degraded: runtime keeps last-known-good snapshot.
- Recovered: emitted once after a successful activation.
- Recovered: emitted once after the next successful activation.
- Repeated failures while already degraded log warnings but do not spam events.
- Startup fail-fast does not emit degraded events because no runtime snapshot exists yet.
- Startup fail-fast does not emit degraded events because runtime never became active.
## Command-path resolution
Credential-sensitive command paths that opt in (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) can resolve supported SecretRefs via gateway snapshot RPC.
- When gateway is running, those command paths read from the active snapshot.
- If a configured SecretRef is required and gateway is unavailable, command resolution fails fast with actionable diagnostics.
- Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`.
- Gateway RPC method used by these command paths: `secrets.resolve`.
## Audit and configure workflow
Use this default operator flow:
Default operator flow:
```bash
openclaw secrets audit --check
@@ -311,26 +353,22 @@ openclaw secrets configure
openclaw secrets audit --check
```
Migration completeness:
- Include `skills.entries.<skillKey>.apiKey` targets when those skills use API keys.
- If `audit --check` still reports plaintext findings after a partial migration, migrate the remaining reported paths and rerun audit.
### `secrets audit`
Findings include:
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`)
- unresolved refs
- precedence shadowing (`auth-profiles` taking priority over config refs)
- legacy residues (`auth.json`, OAuth out-of-scope reminders)
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
- legacy residues (`auth.json`, OAuth reminders)
### `secrets configure`
Interactive helper that:
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
- lets you select secret-bearing fields in `openclaw.json`
- lets you select supported secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for one agent scope
- can create a new `auth-profiles.json` mapping directly in the target picker
- captures SecretRef details (`source`, `provider`, `id`)
- runs preflight resolution
- can apply immediately
@@ -339,10 +377,11 @@ Helpful modes:
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
- `openclaw secrets configure --agent <id>`
`configure` apply defaults to:
`configure` apply defaults:
- scrub matching static creds from `auth-profiles.json` for targeted providers
- scrub matching static credentials from `auth-profiles.json` for targeted providers
- scrub legacy static `api_key` entries from `auth.json`
- scrub matching known secret lines from `<config-dir>/.env`
@@ -361,26 +400,31 @@ For strict target/path contract details and exact rejection rules, see:
## One-way safety policy
OpenClaw intentionally does **not** write rollback backups that contain pre-migration plaintext secret values.
OpenClaw intentionally does not write rollback backups containing historical plaintext secret values.
Safety model:
- preflight must succeed before write mode
- runtime activation is validated before commit
- apply updates files using atomic file replacement and best-effort in-memory restore on failure
- apply updates files using atomic file replacement and best-effort restore on failure
## `auth.json` compatibility notes
## Legacy auth compatibility notes
For static credentials, OpenClaw runtime no longer depends on plaintext `auth.json`.
For static credentials, runtime no longer depends on plaintext legacy auth storage.
- Runtime credential source is the resolved in-memory snapshot.
- Legacy `auth.json` static `api_key` entries are scrubbed when discovered.
- OAuth-related legacy compatibility behavior remains separate.
- Legacy static `api_key` entries are scrubbed when discovered.
- OAuth-related compatibility behavior remains separate.
## Web UI note
Some SecretInput unions are easier to configure in raw editor mode than in form mode.
## Related docs
- CLI commands: [secrets](/cli/secrets)
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
- Auth setup: [Authentication](/gateway/authentication)
- Security posture: [Security](/gateway/security)
- Environment precedence: [Environment Variables](/help/environment)

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,123 @@
---
summary: "Canonical supported vs unsupported SecretRef credential surface"
read_when:
- Verifying SecretRef credential coverage
- Auditing whether a credential is eligible for `secrets configure` or `secrets apply`
- Verifying why a credential is outside the supported surface
title: "SecretRef Credential Surface"
---
# SecretRef credential surface
This page defines the canonical SecretRef credential surface.
Scope intent:
- In scope: strictly user-supplied credentials that OpenClaw does not mint or rotate.
- Out of scope: runtime-minted or rotating credentials, OAuth refresh material, and session-like artifacts.
## Supported credentials
### `openclaw.json` targets (`secrets configure` + `secrets apply` + `secrets audit`)
<!-- secretref-supported-list-start -->
- `models.providers.*.apiKey`
- `skills.entries.*.apiKey`
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].memorySearch.remote.apiKey`
- `talk.apiKey`
- `talk.providers.*.apiKey`
- `messages.tts.elevenlabs.apiKey`
- `messages.tts.openai.apiKey`
- `tools.web.search.apiKey`
- `tools.web.search.gemini.apiKey`
- `tools.web.search.grok.apiKey`
- `tools.web.search.kimi.apiKey`
- `tools.web.search.perplexity.apiKey`
- `gateway.auth.password`
- `gateway.remote.token`
- `gateway.remote.password`
- `cron.webhookToken`
- `channels.telegram.botToken`
- `channels.telegram.webhookSecret`
- `channels.telegram.accounts.*.botToken`
- `channels.telegram.accounts.*.webhookSecret`
- `channels.slack.botToken`
- `channels.slack.appToken`
- `channels.slack.userToken`
- `channels.slack.signingSecret`
- `channels.slack.accounts.*.botToken`
- `channels.slack.accounts.*.appToken`
- `channels.slack.accounts.*.userToken`
- `channels.slack.accounts.*.signingSecret`
- `channels.discord.token`
- `channels.discord.pluralkit.token`
- `channels.discord.voice.tts.elevenlabs.apiKey`
- `channels.discord.voice.tts.openai.apiKey`
- `channels.discord.accounts.*.token`
- `channels.discord.accounts.*.pluralkit.token`
- `channels.discord.accounts.*.voice.tts.elevenlabs.apiKey`
- `channels.discord.accounts.*.voice.tts.openai.apiKey`
- `channels.irc.password`
- `channels.irc.nickserv.password`
- `channels.irc.accounts.*.password`
- `channels.irc.accounts.*.nickserv.password`
- `channels.bluebubbles.password`
- `channels.bluebubbles.accounts.*.password`
- `channels.feishu.appSecret`
- `channels.feishu.verificationToken`
- `channels.feishu.accounts.*.appSecret`
- `channels.feishu.accounts.*.verificationToken`
- `channels.msteams.appPassword`
- `channels.mattermost.botToken`
- `channels.mattermost.accounts.*.botToken`
- `channels.matrix.password`
- `channels.matrix.accounts.*.password`
- `channels.nextcloud-talk.botSecret`
- `channels.nextcloud-talk.apiPassword`
- `channels.nextcloud-talk.accounts.*.botSecret`
- `channels.nextcloud-talk.accounts.*.apiPassword`
- `channels.zalo.botToken`
- `channels.zalo.webhookSecret`
- `channels.zalo.accounts.*.botToken`
- `channels.zalo.accounts.*.webhookSecret`
- `channels.googlechat.serviceAccount` via sibling `serviceAccountRef` (compatibility exception)
- `channels.googlechat.accounts.*.serviceAccount` via sibling `serviceAccountRef` (compatibility exception)
### `auth-profiles.json` targets (`secrets configure` + `secrets apply` + `secrets audit`)
- `profiles.*.keyRef` (`type: "api_key"`)
- `profiles.*.tokenRef` (`type: "token"`)
<!-- secretref-supported-list-end -->
Notes:
- Auth-profile plan targets require `agentId`.
- Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`).
- Auth-profile refs are included in runtime resolution and audit coverage.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
## Unsupported credentials
Out-of-scope credentials include:
<!-- secretref-unsupported-list-start -->
- `gateway.auth.token`
- `commands.ownerDisplaySecret`
- `channels.matrix.accessToken`
- `channels.matrix.accounts.*.accessToken`
- `hooks.token`
- `hooks.gmail.pushToken`
- `hooks.mappings[].sessionKey`
- `auth-profiles.oauth.*`
- `discord.threadBindings.*.webhookToken`
- `whatsapp.creds.json`
<!-- secretref-unsupported-list-end -->
Rationale:
- These credentials are minted, rotated, session-bearing, or OAuth-durable classes that do not fit read-only external SecretRef resolution.

View File

@@ -0,0 +1,480 @@
{
"version": 1,
"matrixId": "strictly-user-supplied-credentials",
"pathSyntax": "Dot path with \"*\" for map keys and \"[]\" for arrays.",
"scope": "Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.",
"excludedMutableOrRuntimeManaged": [
"commands.ownerDisplaySecret",
"channels.matrix.accessToken",
"channels.matrix.accounts.*.accessToken",
"gateway.auth.token",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",
"auth-profiles.oauth.*",
"discord.threadBindings.*.webhookToken",
"whatsapp.creds.json"
],
"entries": [
{
"id": "agents.defaults.memorySearch.remote.apiKey",
"configFile": "openclaw.json",
"path": "agents.defaults.memorySearch.remote.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].memorySearch.remote.apiKey",
"configFile": "openclaw.json",
"path": "agents.list[].memorySearch.remote.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "auth-profiles.api_key.key",
"configFile": "auth-profiles.json",
"path": "profiles.*.key",
"refPath": "profiles.*.keyRef",
"when": {
"type": "api_key"
},
"secretShape": "sibling_ref",
"optIn": true
},
{
"id": "auth-profiles.token.token",
"configFile": "auth-profiles.json",
"path": "profiles.*.token",
"refPath": "profiles.*.tokenRef",
"when": {
"type": "token"
},
"secretShape": "sibling_ref",
"optIn": true
},
{
"id": "channels.bluebubbles.accounts.*.password",
"configFile": "openclaw.json",
"path": "channels.bluebubbles.accounts.*.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.bluebubbles.password",
"configFile": "openclaw.json",
"path": "channels.bluebubbles.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.accounts.*.pluralkit.token",
"configFile": "openclaw.json",
"path": "channels.discord.accounts.*.pluralkit.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.accounts.*.token",
"configFile": "openclaw.json",
"path": "channels.discord.accounts.*.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
"configFile": "openclaw.json",
"path": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.accounts.*.voice.tts.openai.apiKey",
"configFile": "openclaw.json",
"path": "channels.discord.accounts.*.voice.tts.openai.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.pluralkit.token",
"configFile": "openclaw.json",
"path": "channels.discord.pluralkit.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.token",
"configFile": "openclaw.json",
"path": "channels.discord.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.voice.tts.elevenlabs.apiKey",
"configFile": "openclaw.json",
"path": "channels.discord.voice.tts.elevenlabs.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.discord.voice.tts.openai.apiKey",
"configFile": "openclaw.json",
"path": "channels.discord.voice.tts.openai.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.feishu.accounts.*.appSecret",
"configFile": "openclaw.json",
"path": "channels.feishu.accounts.*.appSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.feishu.accounts.*.verificationToken",
"configFile": "openclaw.json",
"path": "channels.feishu.accounts.*.verificationToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.feishu.appSecret",
"configFile": "openclaw.json",
"path": "channels.feishu.appSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.feishu.verificationToken",
"configFile": "openclaw.json",
"path": "channels.feishu.verificationToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.googlechat.accounts.*.serviceAccount",
"configFile": "openclaw.json",
"path": "channels.googlechat.accounts.*.serviceAccount",
"refPath": "channels.googlechat.accounts.*.serviceAccountRef",
"secretShape": "sibling_ref",
"optIn": true,
"notes": "Google Chat compatibility exception: sibling ref field remains canonical."
},
{
"id": "channels.googlechat.serviceAccount",
"configFile": "openclaw.json",
"path": "channels.googlechat.serviceAccount",
"refPath": "channels.googlechat.serviceAccountRef",
"secretShape": "sibling_ref",
"optIn": true,
"notes": "Google Chat compatibility exception: sibling ref field remains canonical."
},
{
"id": "channels.irc.accounts.*.nickserv.password",
"configFile": "openclaw.json",
"path": "channels.irc.accounts.*.nickserv.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.irc.accounts.*.password",
"configFile": "openclaw.json",
"path": "channels.irc.accounts.*.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.irc.nickserv.password",
"configFile": "openclaw.json",
"path": "channels.irc.nickserv.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.irc.password",
"configFile": "openclaw.json",
"path": "channels.irc.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.matrix.accounts.*.password",
"configFile": "openclaw.json",
"path": "channels.matrix.accounts.*.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.matrix.password",
"configFile": "openclaw.json",
"path": "channels.matrix.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.mattermost.accounts.*.botToken",
"configFile": "openclaw.json",
"path": "channels.mattermost.accounts.*.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.mattermost.botToken",
"configFile": "openclaw.json",
"path": "channels.mattermost.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.msteams.appPassword",
"configFile": "openclaw.json",
"path": "channels.msteams.appPassword",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.nextcloud-talk.accounts.*.apiPassword",
"configFile": "openclaw.json",
"path": "channels.nextcloud-talk.accounts.*.apiPassword",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.nextcloud-talk.accounts.*.botSecret",
"configFile": "openclaw.json",
"path": "channels.nextcloud-talk.accounts.*.botSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.nextcloud-talk.apiPassword",
"configFile": "openclaw.json",
"path": "channels.nextcloud-talk.apiPassword",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.nextcloud-talk.botSecret",
"configFile": "openclaw.json",
"path": "channels.nextcloud-talk.botSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.appToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.appToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.botToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.signingSecret",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.signingSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.userToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.userToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.appToken",
"configFile": "openclaw.json",
"path": "channels.slack.appToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.botToken",
"configFile": "openclaw.json",
"path": "channels.slack.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.signingSecret",
"configFile": "openclaw.json",
"path": "channels.slack.signingSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.userToken",
"configFile": "openclaw.json",
"path": "channels.slack.userToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.telegram.accounts.*.botToken",
"configFile": "openclaw.json",
"path": "channels.telegram.accounts.*.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.telegram.accounts.*.webhookSecret",
"configFile": "openclaw.json",
"path": "channels.telegram.accounts.*.webhookSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.telegram.botToken",
"configFile": "openclaw.json",
"path": "channels.telegram.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.telegram.webhookSecret",
"configFile": "openclaw.json",
"path": "channels.telegram.webhookSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.zalo.accounts.*.botToken",
"configFile": "openclaw.json",
"path": "channels.zalo.accounts.*.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.zalo.accounts.*.webhookSecret",
"configFile": "openclaw.json",
"path": "channels.zalo.accounts.*.webhookSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.zalo.botToken",
"configFile": "openclaw.json",
"path": "channels.zalo.botToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.zalo.webhookSecret",
"configFile": "openclaw.json",
"path": "channels.zalo.webhookSecret",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "cron.webhookToken",
"configFile": "openclaw.json",
"path": "cron.webhookToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "gateway.auth.password",
"configFile": "openclaw.json",
"path": "gateway.auth.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "gateway.remote.password",
"configFile": "openclaw.json",
"path": "gateway.remote.password",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "gateway.remote.token",
"configFile": "openclaw.json",
"path": "gateway.remote.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "messages.tts.elevenlabs.apiKey",
"configFile": "openclaw.json",
"path": "messages.tts.elevenlabs.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "messages.tts.openai.apiKey",
"configFile": "openclaw.json",
"path": "messages.tts.openai.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.apiKey",
"configFile": "openclaw.json",
"path": "models.providers.*.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "skills.entries.*.apiKey",
"configFile": "openclaw.json",
"path": "skills.entries.*.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "talk.apiKey",
"configFile": "openclaw.json",
"path": "talk.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "talk.providers.*.apiKey",
"configFile": "openclaw.json",
"path": "talk.providers.*.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.search.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.gemini.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.search.gemini.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.grok.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.search.grok.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.kimi.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.search.kimi.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.perplexity.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.search.perplexity.apiKey",
"secretShape": "secret_input",
"optIn": true
}
]
}

View File

@@ -313,7 +313,7 @@ See [Configuration Reference](/gateway/configuration-reference).
Install and enable plugin:
```bash
openclaw plugins install @openclaw/acpx
openclaw plugins install acpx
openclaw config set plugins.entries.acpx.enabled true
```
@@ -331,7 +331,7 @@ Then verify backend health:
### acpx command and version configuration
By default, `@openclaw/acpx` uses the plugin-local pinned binary:
By default, the acpx plugin (published as `@openclaw/acpx`) uses the plugin-local pinned binary:
1. Command defaults to `extensions/acpx/node_modules/.bin/acpx`.
2. Expected version defaults to the extension pin.

View File

@@ -1,5 +1,5 @@
---
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)"
summary: "Web search + fetch tools (Brave, Perplexity, Gemini, Grok, and Kimi providers)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave Search API key setup
@@ -12,7 +12,7 @@ title: "Web Tools"
OpenClaw ships two lightweight web tools:
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding.
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, Gemini with Google Search grounding, Grok, or Kimi.
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@@ -36,6 +36,8 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
@@ -43,10 +45,11 @@ See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for
If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order:
1. **Brave**`BRAVE_API_KEY` env var or `search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `search.gemini.apiKey` config
3. **Perplexity**`PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config
4. **Grok**`XAI_API_KEY` env var or `search.grok.apiKey` config
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
5. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
@@ -59,7 +62,7 @@ Set the provider in config:
tools: {
web: {
search: {
provider: "brave", // or "perplexity" or "gemini"
provider: "brave", // or "perplexity" or "gemini" or "grok" or "kimi"
},
},
},
@@ -208,6 +211,9 @@ Search the web using your configured provider.
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
### Config

View File

@@ -201,6 +201,19 @@ openclaw channels add
}
```
若使用 `connectionMode: "webhook"`,需设置 `verificationToken`。飞书 Webhook 服务默认绑定 `127.0.0.1`;仅在需要不同监听地址时设置 `webhookHost`
#### 获取 Verification Token仅 Webhook 模式)
使用 Webhook 模式时,需在配置中设置 `channels.feishu.verificationToken`。获取方式:
1. 在飞书开放平台打开您的应用
2. 进入 **开发配置****事件与回调**
3. 打开 **加密策略** 选项卡
4. 复制 **Verification Token**(校验令牌)
![Verification Token 位置](/images/feishu-verification-token.png)
### 通过环境变量配置
```bash
@@ -228,6 +241,34 @@ export FEISHU_APP_SECRET="xxx"
}
```
### 配额优化
可通过以下可选配置减少飞书 API 调用:
- `typingIndicator`(默认 `true`):设为 `false` 时不发送“正在输入”状态。
- `resolveSenderNames`(默认 `true`):设为 `false` 时不拉取发送者资料。
可在渠道级或账号级配置:
```json5
{
channels: {
feishu: {
typingIndicator: false,
resolveSenderNames: false,
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
typingIndicator: true,
resolveSenderNames: false,
},
},
},
},
}
```
---
## 第三步:启动并测试
@@ -280,7 +321,7 @@ openclaw pairing approve feishu <配对码>
**1. 群组策略**`channels.feishu.groupPolicy`
- `"open"` = 允许群组中所有人(默认)
- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户
- `"allowlist"` = 仅允许 `groupAllowFrom` 中的群组
- `"disabled"` = 禁用群组消息
**2. @提及要求**`channels.feishu.groups.<chat_id>.requireMention`
@@ -321,14 +362,36 @@ openclaw pairing approve feishu <配对码>
}
```
### 仅允许特定用户在群组中使用
### 仅允许特定群组
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
groupAllowFrom: ["ou_xxx", "ou_yyy"],
// 群组 ID 格式为 oc_xxx
groupAllowFrom: ["oc_xxx", "oc_yyy"],
},
},
}
```
### 仅允许特定成员在群组中发信(发送者白名单)
除群组白名单外,该群组内**所有消息**均按发送者 open_id 校验:仅 `groups.<chat_id>.allowFrom` 中列出的用户消息会被处理,其他成员的消息会被忽略(此为发送者级白名单,不仅针对 /reset、/new 等控制命令)。
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
groupAllowFrom: ["oc_xxx"],
groups: {
oc_xxx: {
// 用户 open_id 格式为 ou_xxx
allowFrom: ["ou_user1", "ou_user2"],
},
},
},
},
}
@@ -428,12 +491,13 @@ openclaw pairing list feishu
### 多账号配置
如果需要管理多个飞书机器人:
如果需要管理多个飞书机器人,可配置 `defaultAccount` 指定出站未显式指定 `accountId` 时使用的账号
```json5
{
channels: {
feishu: {
defaultAccount: "main",
accounts: {
main: {
appId: "cli_xxx",
@@ -578,23 +642,29 @@ openclaw pairing list feishu
主要选项:
| 配置项 | 说明 | 默认值 |
| ------------------------------------------------- | ------------------------------ | --------- |
| `channels.feishu.enabled` | 启用/禁用渠道 | `true` |
| `channels.feishu.domain` | API 域名(`feishu` 或 `lark` | `feishu` |
| `channels.feishu.accounts.<id>.appId` | 应用 App ID | - |
| `channels.feishu.accounts.<id>.appSecret` | 应用 App Secret | - |
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
| 配置项 | 说明 | 默认值 |
| ------------------------------------------------- | --------------------------------- | ---------------- |
| `channels.feishu.enabled` | 启用/禁用渠道 | `true` |
| `channels.feishu.domain` | API 域名(`feishu` 或 `lark` | `feishu` |
| `channels.feishu.connectionMode` | 事件传输模式websocket/webhook | `websocket` |
| `channels.feishu.defaultAccount` | 出站路由默认账号 ID | `default` |
| `channels.feishu.verificationToken` | Webhook 模式必填 | - |
| `channels.feishu.webhookPath` | Webhook 路由路径 | `/feishu/events` |
| `channels.feishu.webhookHost` | Webhook 监听地址 | `127.0.0.1` |
| `channels.feishu.webhookPort` | Webhook 监听端口 | `3000` |
| `channels.feishu.accounts.<id>.appId` | 应用 App ID | - |
| `channels.feishu.accounts.<id>.appSecret` | 应用 App Secret | - |
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
---
@@ -614,6 +684,7 @@ openclaw pairing list feishu
### 接收
- ✅ 文本消息
- ✅ 富文本(帖子)
- ✅ 图片
- ✅ 文件
- ✅ 音频

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
@@ -18,8 +19,24 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
const baseUrl =
normalizeResolvedSecretInputString({
value: params.serverUrl,
path: "channels.bluebubbles.serverUrl",
}) ||
normalizeResolvedSecretInputString({
value: account.config.serverUrl,
path: `channels.bluebubbles.accounts.${account.accountId}.serverUrl`,
});
const password =
normalizeResolvedSecretInputString({
value: params.password,
path: "channels.bluebubbles.password",
}) ||
normalizeResolvedSecretInputString({
value: account.config.password,
path: `channels.bluebubbles.accounts.${account.accountId}.password`,
});
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveBlueBubblesAccount } from "./accounts.js";
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
});

View File

@@ -4,6 +4,7 @@ import {
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
@@ -79,9 +80,9 @@ export function resolveBlueBubblesAccount(params: {
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const serverUrl = merged.serverUrl?.trim();
const password = merged.password?.trim();
const configured = Boolean(serverUrl && password);
const serverUrl = normalizeSecretInputString(merged.serverUrl);
const password = normalizeSecretInputString(merged.password);
const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password));
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
return {
accountId,

View File

@@ -25,6 +25,7 @@ import {
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
@@ -102,8 +103,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
cfg: cfg,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
const password = normalizeSecretInputString(account.config.password);
const opts = { cfg: cfg, accountId: accountId ?? undefined };
const assertPrivateApiEnabled = () => {
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {

View File

@@ -10,6 +10,18 @@ describe("BlueBubblesConfigSchema", () => {
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",

View File

@@ -1,5 +1,6 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -30,7 +31,7 @@ const bluebubblesAccountSchema = z
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(),
password: z.string().optional(),
password: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
@@ -49,8 +50,8 @@ const bluebubblesAccountSchema = z
})
.superRefine((value, ctx) => {
const serverUrl = value.serverUrl?.trim() ?? "";
const password = value.password?.trim() ?? "";
if (serverUrl && !password) {
const passwordConfigured = hasConfiguredSecretInput(value.password);
if (serverUrl && !passwordConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["password"],

View File

@@ -43,6 +43,7 @@ import type {
} from "./monitor-shared.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
@@ -731,8 +732,8 @@ export async function processMessage(
// surfacing dropped content (allowlist/mention/command gating).
cacheInboundMessage();
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
const password = normalizeSecretInputString(account.config.password);
const maxBytes =
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
? account.config.mediaMaxMb * 1024 * 1024

View File

@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import {
@@ -50,8 +50,11 @@ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
const mockResolveAgentRoute = vi.fn(() => ({
agentId: "main",
channel: "bluebubbles",
accountId: "default",
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
@@ -66,131 +69,57 @@ const mockMatchesMentionWithExplicit = vi.fn(
},
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open");
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (_params: DispatchReplyParams): Promise<void> => undefined,
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
id: "test-media.jpg",
path: "/tmp/test-media.jpg",
size: Buffer.byteLength("test"),
contentType: "image/jpeg",
});
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
template: "channel+name+time",
}));
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockResolveChunkMode = vi.fn(() => "length");
const mockResolveChunkMode = vi.fn(() => "length" as const);
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
function createMockRuntime(): PluginRuntime {
return {
version: "1.0.0",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
return createPluginRuntimeMock({
system: {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
enqueueSystemEvent: mockEnqueueSystemEvent,
},
channel: {
text: {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
chunkByNewline:
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
chunkMarkdownTextWithMode:
mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
chunkTextWithMode:
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
chunkMarkdownText: mockChunkMarkdownText,
chunkByNewline: mockChunkByNewline,
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
chunkTextWithMode: mockChunkTextWithMode,
resolveChunkMode:
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand:
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn(
(text: string) => text,
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
hasControlCommand: mockHasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope:
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
@@ -199,105 +128,33 @@ function createMockRuntime(): PluginRuntime {
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply:
mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore:
mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest:
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
},
mentions: {
buildMentionRegexes:
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns:
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
matchesMentionWithExplicit:
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention:
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
// Create a pass-through debouncer that immediately calls onFlush
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
resolveRequireMention: mockResolveRequireMention,
},
commands: {
resolveCommandAuthorizedFromAuthorizers:
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(
() => false,
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
},
state: {
resolveStateDir: vi.fn(
() => "/tmp/openclaw",
) as unknown as PluginRuntime["state"]["resolveStateDir"],
},
};
});
}
function createMockAccount(
@@ -404,604 +261,6 @@ describe("BlueBubbles webhook monitor", () => {
unregister?.();
});
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
});
it("accepts POST requests with valid JSON payload", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("rejects requests with invalid JSON", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
});
it("accepts URL-encoded payload wrappers", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = vi.fn();
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
// Advance past the 30s timeout
await vi.advanceTimersByTimeAsync(31_000);
const handled = await handledPromise;
expect(handled).toBe(true);
expect(res.statusCode).toBe(408);
expect(req.destroy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
{ "x-password": "secret-token" },
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
});
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkA = vi.fn();
const sinkB = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkA,
});
const unregisterB = registerBlueBubblesWebhookTarget({
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkB,
});
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
});
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkStrict,
});
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
account: accountWithoutPassword,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkWithoutPassword,
});
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkWithoutPassword).not.toHaveBeenCalled();
});
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
loopbackUnregister();
}
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const headerVariants: Record<string, string>[] = [
{ host: "localhost" },
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
}
});
it("ignores unregistered webhook paths", async () => {
const req = createMockRequest("POST", "/unregistered-path", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(false);
});
it("parses chatId when provided as a string (webhook variant)", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_id", chatId: 123 },
}),
);
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"chat_guid:iMessage;+;chat123456",
expect.any(String),
expect.any(Object),
);
});
});
describe("DM pairing behavior vs allowFrom", () => {
it("allows DM from sender in allowFrom list", async () => {
const account = createMockAccount({
@@ -2508,6 +1767,7 @@ describe("BlueBubbles webhook monitor", () => {
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
return EMPTY_DISPATCH_RESULT;
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2558,6 +1818,7 @@ describe("BlueBubbles webhook monitor", () => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
await params.dispatcherOptions.onIdle?.();
return EMPTY_DISPATCH_RESULT;
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2603,7 +1864,9 @@ describe("BlueBubbles webhook monitor", () => {
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async () => EMPTY_DISPATCH_RESULT,
);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
@@ -2625,6 +1888,7 @@ describe("BlueBubbles webhook monitor", () => {
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const account = createMockAccount();
@@ -2676,6 +1940,7 @@ describe("BlueBubbles webhook monitor", () => {
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const account = createMockAccount();
@@ -2748,6 +2013,7 @@ describe("BlueBubbles webhook monitor", () => {
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const account = createMockAccount();

View File

@@ -0,0 +1,862 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
vi.mock("./chat.js", () => ({
markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined),
sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./attachments.js", () => ({
downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({
buffer: Buffer.from("test"),
contentType: "image/jpeg",
}),
}));
vi.mock("./reactions.js", async () => {
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
return {
...actual,
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("./history.js", () => ({
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
}));
// Mock runtime
const mockEnqueueSystemEvent = vi.fn();
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
const mockResolveAgentRoute = vi.fn(() => ({
agentId: "main",
channel: "bluebubbles",
accountId: "default",
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((r) => r.test(text)),
);
const mockMatchesMentionWithExplicit = vi.fn(
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
if (params.explicitWasMentioned) {
return true;
}
return params.mentionRegexes.some((regex) => regex.test(params.text));
},
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
id: "test-media.jpg",
path: "/tmp/test-media.jpg",
size: Buffer.byteLength("test"),
contentType: "image/jpeg",
});
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockResolveChunkMode = vi.fn(() => "length" as const);
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
function createMockRuntime(): PluginRuntime {
return createPluginRuntimeMock({
system: {
enqueueSystemEvent: mockEnqueueSystemEvent,
},
channel: {
text: {
chunkMarkdownText: mockChunkMarkdownText,
chunkByNewline: mockChunkByNewline,
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
chunkTextWithMode: mockChunkTextWithMode,
resolveChunkMode:
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
hasControlCommand: mockHasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
},
media: {
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
},
mentions: {
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention,
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
},
});
}
function createMockAccount(
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
): ResolvedBlueBubblesAccount {
return {
accountId: "default",
enabled: true,
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password",
dmPolicy: "open",
groupPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
...overrides,
},
};
}
function createMockRequest(
method: string,
url: string,
body: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
if (headers.host === undefined) {
headers.host = "localhost";
}
const parsedUrl = new URL(url, "http://localhost");
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
const hasAuthHeader =
headers["x-guid"] !== undefined ||
headers["x-password"] !== undefined ||
headers["x-bluebubbles-guid"] !== undefined ||
headers.authorization !== undefined;
if (!hasAuthQuery && !hasAuthHeader) {
parsedUrl.searchParams.set("password", "test-password");
}
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
req.headers = headers;
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
// Emit body data after a microtask
// oxlint-disable-next-line no-floating-promises
Promise.resolve().then(() => {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
req.emit("data", Buffer.from(bodyStr));
req.emit("end");
});
return req;
}
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
const res = {
statusCode: 200,
body: "",
setHeader: vi.fn(),
end: vi.fn((data?: string) => {
res.body = data ?? "";
}),
} as unknown as ServerResponse & { body: string; statusCode: number };
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
function getFirstDispatchCall(): DispatchReplyParams {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
if (!callArgs) {
throw new Error("expected dispatch call arguments");
}
return callArgs;
}
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
beforeEach(() => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
mockResolveRequireMention.mockReturnValue(false);
mockHasControlCommand.mockReturnValue(false);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
setBlueBubblesRuntime(createMockRuntime());
});
afterEach(() => {
unregister?.();
});
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
});
it("accepts POST requests with valid JSON payload", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("rejects requests with invalid JSON", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
});
it("accepts URL-encoded payload wrappers", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = vi.fn();
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
// Advance past the 30s timeout
await vi.advanceTimersByTimeAsync(31_000);
const handled = await handledPromise;
expect(handled).toBe(true);
expect(res.statusCode).toBe(408);
expect(req.destroy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
{ "x-password": "secret-token" },
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
});
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkA = vi.fn();
const sinkB = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkA,
});
const unregisterB = registerBlueBubblesWebhookTarget({
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkB,
});
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
});
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkStrict,
});
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
account: accountWithoutPassword,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkWithoutPassword,
});
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkWithoutPassword).not.toHaveBeenCalled();
});
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
loopbackUnregister();
}
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const headerVariants: Record<string, string>[] = [
{ host: "localhost" },
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
}
});
it("ignores unregistered webhook paths", async () => {
const req = createMockRequest("POST", "/unregistered-path", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(false);
});
it("parses chatId when provided as a string (webhook variant)", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_id", chatId: 123 },
}),
);
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"chat_guid:iMessage;+;chat123456",
expect.any(String),
expect.any(Object),
);
});
});
});

View File

@@ -0,0 +1,81 @@
import type { WizardPrompter } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk", () => ({
DEFAULT_ACCOUNT_ID: "default",
addWildcardAllowFrom: vi.fn(),
formatDocsLink: (_url: string, fallback: string) => fallback,
hasConfiguredSecretInput: (value: unknown) => {
if (typeof value === "string") {
return value.trim().length > 0;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const ref = value as { source?: unknown; provider?: unknown; id?: unknown };
const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec";
return (
validSource &&
typeof ref.provider === "string" &&
ref.provider.trim().length > 0 &&
typeof ref.id === "string" &&
ref.id.trim().length > 0
);
},
mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries,
normalizeSecretInputString: (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
},
normalizeAccountId: (value?: string | null) =>
value && value.trim().length > 0 ? value : "default",
promptAccountId: vi.fn(),
}));
describe("bluebubbles onboarding SecretInput", () => {
it("preserves existing password SecretRef when user keeps current credential", async () => {
const { blueBubblesOnboardingAdapter } = await import("./onboarding.js");
type ConfigureContext = Parameters<
NonNullable<typeof blueBubblesOnboardingAdapter.configure>
>[0];
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
const confirm = vi
.fn()
.mockResolvedValueOnce(true) // keep server URL
.mockResolvedValueOnce(true) // keep password SecretRef
.mockResolvedValueOnce(false); // keep default webhook path
const text = vi.fn();
const note = vi.fn();
const prompter = {
confirm,
text,
note,
} as unknown as WizardPrompter;
const context = {
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://127.0.0.1:1234",
password: passwordRef,
},
},
},
prompter,
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
forceAllowFrom: false,
accountOverrides: {},
shouldPromptAccountIds: false,
} satisfies ConfigureContext;
const result = await blueBubblesOnboardingAdapter.configure(context);
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
expect(text).not.toHaveBeenCalled();
});
});

View File

@@ -18,6 +18,7 @@ import {
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
@@ -222,8 +223,11 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
}
// Prompt for password
let password = resolvedAccount.config.password?.trim();
if (!password) {
const existingPassword = resolvedAccount.config.password;
const existingPasswordText = normalizeSecretInputString(existingPassword);
const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword);
let password: unknown = existingPasswordText;
if (!hasConfiguredPassword) {
await prompter.note(
[
"Enter the BlueBubbles server password.",
@@ -247,6 +251,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
password = String(entered).trim();
} else if (!existingPasswordText) {
password = existingPassword;
}
}

View File

@@ -1,4 +1,5 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
export type BlueBubblesProbe = BaseProbeResult & {
@@ -35,8 +36,8 @@ export async function fetchBlueBubblesServerInfo(params: {
accountId?: string;
timeoutMs?: number;
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl || !password) {
return null;
}
@@ -138,8 +139,8 @@ export async function probeBlueBubbles(params: {
password?: string | null;
timeoutMs?: number;
}): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl) {
return { ok: false, error: "serverUrl not configured" };
}

View File

@@ -0,0 +1,19 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk";
import { z } from "zod";
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
export function buildSecretInputSchema() {
return z.union([
z.string(),
z.object({
source: z.enum(["env", "file", "exec"]),
provider: z.string().min(1),
id: z.string().min(1),
}),
]);
}

View File

@@ -7,6 +7,7 @@ import {
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { warnBlueBubbles } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
@@ -372,8 +373,12 @@ export async function sendMessageBlueBubbles(
cfg: opts.cfg ?? {},
accountId: opts.accountId,
});
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = opts.password?.trim() || account.config.password?.trim();
const baseUrl =
normalizeSecretInputString(opts.serverUrl) ||
normalizeSecretInputString(account.config.serverUrl);
const password =
normalizeSecretInputString(opts.password) ||
normalizeSecretInputString(account.config.password);
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}

View File

@@ -208,9 +208,12 @@ function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
return { error: "Gateway auth is not configured (no token or password)." };
}
function pickFirstDefined(candidates: Array<string | undefined>): string | null {
function pickFirstDefined(candidates: Array<unknown>): string | null {
for (const value of candidates) {
const trimmed = value?.trim();
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
import type {
FeishuConfig,
FeishuAccountConfig,
@@ -107,9 +108,34 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null;
export function resolveFeishuCredentials(
cfg: FeishuConfig | undefined,
options: { allowUnresolvedSecretRef?: boolean },
): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null;
export function resolveFeishuCredentials(
cfg?: FeishuConfig,
options?: { allowUnresolvedSecretRef?: boolean },
): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null {
const appId = cfg?.appId?.trim();
const appSecret = cfg?.appSecret?.trim();
const appSecret = options?.allowUnresolvedSecretRef
? normalizeSecretInputString(cfg?.appSecret)
: normalizeResolvedSecretInputString({
value: cfg?.appSecret,
path: "channels.feishu.appSecret",
});
if (!appId || !appSecret) {
return null;
}
@@ -117,7 +143,13 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
appId,
appSecret,
encryptKey: cfg?.encryptKey?.trim() || undefined,
verificationToken: cfg?.verificationToken?.trim() || undefined,
verificationToken:
(options?.allowUnresolvedSecretRef
? normalizeSecretInputString(cfg?.verificationToken)
: normalizeResolvedSecretInputString({
value: cfg?.verificationToken,
path: "channels.feishu.verificationToken",
})) || undefined,
domain: cfg?.domain ?? "feishu",
};
}

View File

@@ -1,7 +1,14 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { FeishuMessageEvent } from "./bot.js";
import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
import {
buildBroadcastSessionKey,
buildFeishuAgentBody,
handleFeishuMessage,
resolveBroadcastAgents,
toMessageResourceType,
} from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const {
@@ -27,8 +34,10 @@ const {
mockCreateFeishuClient: vi.fn(),
mockResolveAgentRoute: vi.fn(() => ({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:dm:ou-attacker",
mainSessionKey: "agent:main:main",
matchedBy: "default",
})),
}));
@@ -122,7 +131,9 @@ describe("handleFeishuMessage command authorization", () => {
const mockBuildPairingReply = vi.fn(() => "Pairing response");
const mockEnqueueSystemEvent = vi.fn();
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
id: "inbound-clip.mp4",
path: "/tmp/inbound-clip.mp4",
size: Buffer.byteLength("video"),
contentType: "video/mp4",
});
@@ -131,8 +142,10 @@ describe("handleFeishuMessage command authorization", () => {
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:dm:ou-attacker",
mainSessionKey: "agent:main:main",
matchedBy: "default",
});
mockCreateFeishuClient.mockReturnValue({
@@ -143,38 +156,46 @@ describe("handleFeishuMessage command authorization", () => {
},
});
mockEnqueueSystemEvent.mockReset();
setFeishuRuntime({
system: {
enqueueSystemEvent: mockEnqueueSystemEvent,
},
channel: {
routing: {
resolveAgentRoute: mockResolveAgentRoute,
setFeishuRuntime(
createPluginRuntimeMock({
system: {
enqueueSystemEvent: mockEnqueueSystemEvent,
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher: mockWithReplyDispatcher,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
channel: {
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(
() => ({}),
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext:
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher:
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
media: {
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
pairing: {
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
buildPairingReply: mockBuildPairingReply,
},
},
media: {
saveMediaBuffer: mockSaveMediaBuffer,
detectMime: vi.fn(async () => "application/octet-stream"),
},
pairing: {
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
buildPairingReply: mockBuildPairingReply,
},
},
media: {
detectMime: vi.fn(async () => "application/octet-stream"),
},
} as unknown as PluginRuntime);
}),
);
});
it("does not enqueue inbound preview text as system events", async () => {
@@ -1583,3 +1604,349 @@ describe("toMessageResourceType", () => {
expect(toMessageResourceType("sticker")).toBe("file");
});
});
describe("resolveBroadcastAgents", () => {
it("returns agent list when broadcast config has the peerId", () => {
const cfg = { broadcast: { oc_group123: ["susan", "main"] } } as unknown as ClawdbotConfig;
expect(resolveBroadcastAgents(cfg, "oc_group123")).toEqual(["susan", "main"]);
});
it("returns null when no broadcast config", () => {
const cfg = {} as ClawdbotConfig;
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
it("returns null when peerId not in broadcast", () => {
const cfg = { broadcast: { oc_other: ["susan"] } } as unknown as ClawdbotConfig;
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
it("returns null when agent list is empty", () => {
const cfg = { broadcast: { oc_group123: [] } } as unknown as ClawdbotConfig;
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
});
describe("buildBroadcastSessionKey", () => {
it("replaces agent ID prefix in session key", () => {
expect(buildBroadcastSessionKey("agent:main:feishu:group:oc_group123", "main", "susan")).toBe(
"agent:susan:feishu:group:oc_group123",
);
});
it("handles compound peer IDs", () => {
expect(
buildBroadcastSessionKey(
"agent:main:feishu:group:oc_group123:sender:ou_user1",
"main",
"susan",
),
).toBe("agent:susan:feishu:group:oc_group123:sender:ou_user1");
});
it("returns base key unchanged when prefix does not match", () => {
expect(buildBroadcastSessionKey("custom:key:format", "main", "susan")).toBe(
"custom:key:format",
);
});
});
describe("broadcast dispatch", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher = vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
);
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
contentType: "video/mp4",
});
beforeEach(() => {
vi.clearAllMocks();
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:feishu:group:oc-broadcast-group",
matchedBy: "default",
});
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
},
},
});
setFeishuRuntime({
system: {
enqueueSystemEvent: vi.fn(),
},
channel: {
routing: {
resolveAgentRoute: mockResolveAgentRoute,
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher: mockWithReplyDispatcher,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
media: {
saveMediaBuffer: mockSaveMediaBuffer,
},
pairing: {
readAllowFromStore: vi.fn().mockResolvedValue([]),
upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
buildPairingReply: vi.fn(() => "Pairing response"),
},
},
media: {
detectMime: vi.fn(async () => "application/octet-stream"),
},
} as unknown as PluginRuntime);
});
it("dispatches to all broadcast agents when bot is mentioned", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-mentioned",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello @bot" }),
mentions: [
{ key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" },
],
},
};
await handleFeishuMessage({
cfg,
event,
botOpenId: "bot-open-id",
runtime: createRuntimeEnv(),
});
// Both agents should get dispatched
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
// Verify session keys for both agents
const sessionKeys = mockFinalizeInboundContext.mock.calls.map(
(call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey,
);
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
// Active agent (mentioned) gets the real Feishu reply dispatcher
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "main" }),
);
});
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-not-mentioned",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello everyone" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
// No dispatch: requireMention=true and bot not mentioned → returns early.
// The mentioned bot's handler (on another account or same account with
// matching botOpenId) will handle broadcast dispatch for all agents.
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
});
it("preserves single-agent dispatch when no broadcast config", async () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-no-broadcast",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
// Single dispatch (no broadcast)
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
SessionKey: "agent:main:feishu:group:oc-broadcast-group",
}),
);
});
it("cross-account broadcast dedup: second account skips dispatch", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-multi-account-dedup",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
// First account handles broadcast normally
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
accountId: "account-A",
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
mockDispatchReplyFromConfig.mockClear();
mockFinalizeInboundContext.mockClear();
// Second account: same message ID, different account.
// Per-account dedup passes (different namespace), but cross-account
// broadcast dedup blocks dispatch.
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
accountId: "account-B",
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("skips unknown agents not in agents.list", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-unknown-agent",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
// Only susan should get dispatched (unknown-agent skipped)
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string })
.SessionKey;
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
});
});

View File

@@ -6,6 +6,7 @@ import {
createScopedPairingAccess,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
normalizeAgentId,
recordPendingHistoryEntryIfEnabled,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -698,6 +699,31 @@ async function resolveFeishuMediaList(params: {
return out;
}
// --- Broadcast support ---
// Resolve broadcast agent list for a given peer (group) ID.
// Returns null if no broadcast config exists or the peer is not in the broadcast list.
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
const broadcast = (cfg as Record<string, unknown>).broadcast;
if (!broadcast || typeof broadcast !== "object") return null;
const agents = (broadcast as Record<string, unknown>)[peerId];
if (!Array.isArray(agents) || agents.length === 0) return null;
return agents as string[];
}
// Build a session key for a broadcast target agent by replacing the agent ID prefix.
// Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
export function buildBroadcastSessionKey(
baseSessionKey: string,
originalAgentId: string,
targetAgentId: string,
): string {
const prefix = `agent:${originalAgentId}:`;
if (baseSessionKey.startsWith(prefix)) {
return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
}
return baseSessionKey;
}
/**
* Build media payload for inbound context.
* Similar to Discord's buildDiscordMediaPayload().
@@ -901,7 +927,12 @@ export async function handleFeishuMessage(params: {
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
const broadcastAgents = rawBroadcastAgents
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
: null;
let requireMention = false; // DMs never require mention; groups may override below
if (isGroup) {
if (groupConfig?.enabled === false) {
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
@@ -956,17 +987,19 @@ export async function handleFeishuMessage(params: {
}
}
const { requireMention } = resolveFeishuReplyPolicy({
({ requireMention } = resolveFeishuReplyPolicy({
isDirectMessage: false,
globalConfig: feishuCfg,
groupConfig,
});
}));
if (requireMention && !ctx.mentionedBot) {
log(
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
);
if (chatHistories && groupHistoryKey) {
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
// Record to pending history for non-broadcast groups only. For broadcast groups,
// the mentioned handler's broadcast dispatch writes the turn directly into all
// agent sessions — buffering here would cause duplicate replay when this account
// later becomes active via buildPendingHistoryContextFromMap.
if (!broadcastAgents && chatHistories && groupHistoryKey) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: groupHistoryKey,
@@ -1208,82 +1241,230 @@ export async function handleFeishuMessage(params: {
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
// Quote/reply message support: use standard ReplyToId for parent,
// and pass root_id for thread reconstruction.
ReplyToId: ctx.parentId,
RootMessageId: ctx.rootId,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: ctx.senderName ?? ctx.senderOpenId,
SenderId: ctx.senderOpenId,
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,
});
// --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = (
agentSessionKey: string,
agentAccountId: string,
wasMentioned: boolean,
) =>
core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
ReplyToId: ctx.parentId,
RootMessageId: ctx.rootId,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
To: feishuTo,
SessionKey: agentSessionKey,
AccountId: agentAccountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: ctx.senderName ?? ctx.senderOpenId,
SenderId: ctx.senderOpenId,
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,
});
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
? parseInt(event.message.create_time, 10)
: undefined;
const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
messageCreateTimeMs,
});
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
});
if (broadcastAgents) {
// Cross-account dedup: in multi-account setups, Feishu delivers the same
// event to every bot account in the group. Only one account should handle
// broadcast dispatch to avoid duplicate agent sessions and race conditions.
// Uses a shared "broadcast" namespace (not per-account) so the first handler
// to reach this point claims the message; subsequent accounts skip.
if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
log(
`feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
);
return;
}
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
// --- Broadcast dispatch: send message to all configured agents ---
const strategy =
((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
?.strategy || "parallel";
const activeAgentId =
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
const hasKnownAgents = agentIds.length > 0;
log(
`feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
);
const dispatchForAgent = async (agentId: string) => {
if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
log(
`feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
);
return;
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = buildCtxPayloadForAgent(
agentSessionKey,
route.accountId,
ctx.mentionedBot && agentId === activeAgentId,
);
if (agentId === activeAgentId) {
// Active agent: real Feishu dispatcher (responds on Feishu)
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
messageCreateTimeMs,
});
log(
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher,
replyOptions,
}),
});
} else {
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
// mutate observer sessions — only the active agent should execute commands.
delete (agentCtx as Record<string, unknown>).CommandAuthorized;
const noopDispatcher = {
sendToolResult: () => false,
sendBlockReply: () => false,
sendFinalReply: () => false,
waitForIdle: async () => {},
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
markComplete: () => {},
};
log(
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
});
}
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
try {
await dispatchForAgent(agentId);
} catch (err) {
log(
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
);
}
}
} else {
const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
for (let i = 0; i < results.length; i++) {
if (results[i].status === "rejected") {
log(
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
);
}
}
}
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(
`feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
);
} else {
// --- Single-agent dispatch (existing behavior) ---
const ctxPayload = buildCtxPayloadForAgent(
route.sessionKey,
route.accountId,
ctx.mentionedBot,
);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
messageCreateTimeMs,
});
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
);
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
});
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
);
}
} catch (err) {
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
}

View File

@@ -38,6 +38,22 @@ const meta: ChannelMeta = {
order: 70,
};
const secretInputJsonSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
required: ["source", "provider", "id"],
properties: {
source: { type: "string", enum: ["env", "file", "exec"] },
provider: { type: "string", minLength: 1 },
id: { type: "string", minLength: 1 },
},
},
],
} as const;
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@@ -81,9 +97,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
enabled: { type: "boolean" },
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: { type: "string" },
verificationToken: { type: "string" },
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
@@ -122,9 +138,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: { type: "string" },
verificationToken: { type: "string" },
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },

View File

@@ -95,6 +95,19 @@ describe("createFeishuWSClient proxy handling", () => {
expect(options.agent).toEqual({ proxyUrl: expectedProxy });
});
it("accepts lowercase https_proxy when it is the configured HTTPS proxy var", () => {
process.env.https_proxy = "http://lower-https:8001";
createFeishuWSClient(baseAccount);
const expectedHttpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
expect(expectedHttpsProxy).toBeTruthy();
expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedHttpsProxy);
const options = firstWsClientOptions();
expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
});
it("passes HTTP_PROXY to ws client when https vars are unset", () => {
process.env.HTTP_PROXY = "http://upper-http:8999";

View File

@@ -85,6 +85,25 @@ describe("FeishuConfigSchema webhook validation", () => {
expect(result.success).toBe(true);
});
it("accepts SecretRef verificationToken in webhook mode", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: {
source: "env",
provider: "default",
id: "FEISHU_VERIFICATION_TOKEN",
},
appId: "cli_top",
appSecret: {
source: "env",
provider: "default",
id: "FEISHU_APP_SECRET",
},
});
expect(result.success).toBe(true);
});
});
describe("FeishuConfigSchema replyInThread", () => {

View File

@@ -1,6 +1,7 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { z } from "zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
@@ -180,9 +181,9 @@ export const FeishuAccountConfigSchema = z
enabled: z.boolean().optional(),
name: z.string().optional(), // Display name for this account
appId: z.string().optional(),
appSecret: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
webhookPath: z.string().optional(),
@@ -198,9 +199,9 @@ export const FeishuConfigSchema = z
defaultAccount: z.string().optional(),
// Top-level credentials (backward compatible for single-account mode)
appId: z.string().optional(),
appSecret: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
@@ -234,8 +235,8 @@ export const FeishuConfigSchema = z
}
const defaultConnectionMode = value.connectionMode ?? "websocket";
const defaultVerificationToken = value.verificationToken?.trim();
if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["verificationToken"],
@@ -252,9 +253,9 @@ export const FeishuConfigSchema = z
if (accountConnectionMode !== "webhook") {
continue;
}
const accountVerificationToken =
account.verificationToken?.trim() || defaultVerificationToken;
if (!accountVerificationToken) {
const accountVerificationTokenConfigured =
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
if (!accountVerificationTokenConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accounts", accountId, "verificationToken"],

View File

@@ -1,10 +1,11 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
import * as dedup from "./dedup.js";
import { monitorSingleAccount } from "./monitor.account.js";
@@ -367,17 +368,19 @@ describe("Feishu inbound debounce regressions", () => {
vi.useFakeTimers();
handlers = {};
handleFeishuMessageMock.mockClear();
setFeishuRuntime({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
text: {
hasControlCommand,
},
},
text: {
hasControlCommand,
},
},
} as unknown as PluginRuntime);
}),
);
});
afterEach(() => {

View File

@@ -0,0 +1,25 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import { feishuOnboardingAdapter } from "./onboarding.js";
describe("feishu onboarding status", () => {
it("treats SecretRef appSecret as configured when appId is present", async () => {
const status = await feishuOnboardingAdapter.getStatus({
cfg: {
channels: {
feishu: {
appId: "cli_a123456",
appSecret: {
source: "env",
provider: "default",
id: "FEISHU_APP_SECRET",
},
},
},
} as OpenClawConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
});
});

View File

@@ -3,9 +3,16 @@ import type {
ChannelOnboardingDmPolicy,
ClawdbotConfig,
DmPolicy,
SecretInput,
WizardPrompter,
} from "openclaw/plugin-sdk";
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
promptSingleChannelSecretInput,
} from "openclaw/plugin-sdk";
import { resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
import type { FeishuConfig } from "./types.js";
@@ -104,23 +111,18 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
);
}
async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
appId: string;
appSecret: string;
}> {
async function promptFeishuAppId(params: {
prompter: WizardPrompter;
initialValue?: string;
}): Promise<string> {
const appId = String(
await prompter.text({
await params.prompter.text({
message: "Enter Feishu App ID",
initialValue: params.initialValue,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { appId, appSecret };
return appId;
}
function setFeishuGroupPolicy(
@@ -167,13 +169,30 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
const topLevelConfigured = Boolean(
feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret),
);
const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
if (!account || typeof account !== "object") {
return false;
}
const accountAppId =
typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim();
const accountSecretConfigured =
hasConfiguredSecretInput(account.appSecret) ||
hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppId && accountSecretConfigured);
});
const configured = topLevelConfigured || accountConfigured;
const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
allowUnresolvedSecretRef: true,
});
// Try to probe if configured
let probeResult = null;
if (configured && feishuCfg) {
if (configured && resolvedCredentials) {
try {
probeResult = await probeFeishu(feishuCfg);
probeResult = await probeFeishu(resolvedCredentials);
} catch {
// Ignore probe errors
}
@@ -201,52 +220,53 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
configure: async ({ cfg, prompter }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const resolved = resolveFeishuCredentials(feishuCfg);
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
const resolved = resolveFeishuCredentials(feishuCfg, {
allowUnresolvedSecretRef: true,
});
const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret);
const canUseEnv = Boolean(
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
);
let next = cfg;
let appId: string | null = null;
let appSecret: string | null = null;
let appSecret: SecretInput | null = null;
let appSecretProbeValue: string | null = null;
if (!resolved) {
await noteFeishuCredentialHelp(prompter);
}
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
initialValue: true,
const appSecretResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "feishu",
credentialLabel: "App Secret",
accountConfigured: Boolean(resolved),
canUseEnv,
hasConfigToken: hasConfigSecret,
envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
keepPrompt: "Feishu App Secret already configured. Keep it?",
inputPrompt: "Enter Feishu App Secret",
preferredEnvVar: "FEISHU_APP_SECRET",
});
if (appSecretResult.action === "use-env") {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
} else if (appSecretResult.action === "set") {
appSecret = appSecretResult.value;
appSecretProbeValue = appSecretResult.resolvedValue;
appId = await promptFeishuAppId({
prompter,
initialValue: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(),
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
} else {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: "Feishu credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
if (appId && appSecret) {
@@ -264,9 +284,12 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
};
// Test connection
const testCfg = next.channels?.feishu as FeishuConfig;
try {
const probe = await probeFeishu(testCfg);
const probe = await probeFeishu({
appId,
appSecret: appSecretProbeValue ?? undefined,
domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain,
});
if (probe.ok) {
await prompter.note(
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
@@ -283,6 +306,75 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
const currentMode =
(next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket";
const connectionMode = (await prompter.select({
message: "Feishu connection mode",
options: [
{ value: "websocket", label: "WebSocket (default)" },
{ value: "webhook", label: "Webhook" },
],
initialValue: currentMode,
})) as "websocket" | "webhook";
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
connectionMode,
},
},
};
if (connectionMode === "webhook") {
const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
?.verificationToken;
const verificationTokenResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "feishu-webhook",
credentialLabel: "verification token",
accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
canUseEnv: false,
hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
envPrompt: "",
keepPrompt: "Feishu verification token already configured. Keep it?",
inputPrompt: "Enter Feishu verification token",
preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
});
if (verificationTokenResult.action === "set") {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
verificationToken: verificationTokenResult.value,
},
},
};
}
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
const webhookPath = String(
await prompter.text({
message: "Feishu webhook path",
initialValue: currentWebhookPath ?? "/feishu/events",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
webhookPath,
},
},
};
}
// Domain selection
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
const domain = await prompter.select({

View File

@@ -226,6 +226,24 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("closes streaming with block text when final reply is missing", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
await options.onIdle?.();
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
});
it("sends media-only payloads as attachments", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,

View File

@@ -146,6 +146,48 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
const mergeStreamingText = (nextText: string) => {
if (!streamText) {
streamText = nextText;
return;
}
if (nextText.startsWith(streamText)) {
// Handle cumulative partial payloads where nextText already includes prior text.
streamText = nextText;
return;
}
if (streamText.endsWith(nextText)) {
return;
}
streamText += nextText;
};
const queueStreamingUpdate = (
nextText: string,
options?: {
dedupeWithLastPartial?: boolean;
},
) => {
if (!nextText) {
return;
}
if (options?.dedupeWithLastPartial && nextText === lastPartial) {
return;
}
if (options?.dedupeWithLastPartial) {
lastPartial = nextText;
}
mergeStreamingText(nextText);
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
};
const startStreaming = () => {
if (!streamingEnabled || streamingStartPromise || streaming) {
return;
@@ -205,12 +247,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
void typingCallbacks.onReplyStart?.();
},
deliver: async (payload: ReplyPayload, info) => {
// FIX: Filter out internal 'block' reasoning chunks immediately to prevent
// data leak and race conditions with streaming state initialization.
if (info?.kind === "block") {
return;
}
const text = payload.text ?? "";
const mediaList =
payload.mediaUrls && payload.mediaUrls.length > 0
@@ -228,6 +264,18 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (hasText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
// streaming-card fallback content.
if (!(streamingEnabled && useCard)) {
return;
}
startStreaming();
if (streamingStartPromise) {
await streamingStartPromise;
}
}
if (info?.kind === "final" && streamingEnabled && useCard) {
startStreaming();
if (streamingStartPromise) {
@@ -236,6 +284,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
if (streaming?.isActive()) {
if (info?.kind === "block") {
// Some runtimes emit block payloads without onPartial/final callbacks.
// Mirror block text into streamText so onIdle close still sends content.
queueStreamingUpdate(text);
}
if (info?.kind === "final") {
streamText = text;
await closeStreaming();
@@ -331,19 +384,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
onModelSelected: prefixContext.onModelSelected,
onPartialReply: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text || payload.text === lastPartial) {
if (!payload.text) {
return;
}
lastPartial = payload.text;
streamText = payload.text;
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
queueStreamingUpdate(payload.text, { dedupeWithLastPartial: true });
}
: undefined,
},

View File

@@ -0,0 +1,19 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk";
import { z } from "zod";
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
export function buildSecretInputSchema() {
return z.union([
z.string(),
z.object({
source: z.enum(["env", "file", "exec"]),
provider: z.string().min(1),
id: z.string().min(1),
}),
]);
}

View File

@@ -239,14 +239,15 @@ describe("loginGeminiCliOAuth", () => {
"GOOGLE_CLOUD_PROJECT_ID",
] as const;
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "LINUX" {
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "linux") {
return "LINUX";
if (process.platform === "darwin") {
return "MACOS";
}
return "MACOS";
// Matches updated resolvePlatform() which uses PLATFORM_UNSPECIFIED for Linux
return "PLATFORM_UNSPECIFIED";
}
function getRequestUrl(input: string | URL | Request): string {

View File

@@ -224,14 +224,16 @@ function generatePkce(): { verifier: string; challenge: string } {
return { verifier, challenge };
}
function resolvePlatform(): "WINDOWS" | "MACOS" | "LINUX" {
function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "linux") {
return "LINUX";
if (process.platform === "darwin") {
return "MACOS";
}
return "MACOS";
// Google's loadCodeAssist API rejects "LINUX" as an invalid Platform enum value.
// Use "PLATFORM_UNSPECIFIED" for Linux and other platforms to match the pi-ai runtime.
return "PLATFORM_UNSPECIFIED";
}
async function fetchWithTimeout(

View File

@@ -1,3 +1,4 @@
import { isSecretRef } from "openclaw/plugin-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
@@ -76,6 +77,9 @@ function mergeGoogleChatAccountConfig(
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object") {
if (isSecretRef(value)) {
return null;
}
return value as Record<string, unknown>;
}
if (typeof value !== "string") {
@@ -106,6 +110,18 @@ function resolveCredentialsFromConfig(params: {
return { credentials: inline, source: "inline" };
}
if (isSecretRef(account.serviceAccount)) {
throw new Error(
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccount.source}:${account.serviceAccount.provider}:${account.serviceAccount.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
);
}
if (isSecretRef(account.serviceAccountRef)) {
throw new Error(
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccountRef.source}:${account.serviceAccountRef.provider}:${account.serviceAccountRef.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
);
}
const file = account.serviceAccountFile?.trim();
if (file) {
return { credentialsFile: file, source: "file" };

View File

@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -120,7 +121,10 @@ function resolvePassword(accountId: string, merged: IrcAccountConfig) {
}
}
const configPassword = merged.password?.trim();
const configPassword = normalizeResolvedSecretInputString({
value: merged.password,
path: `channels.irc.accounts.${accountId}.password`,
});
if (configPassword) {
return { password: configPassword, source: "config" as const };
}
@@ -136,7 +140,13 @@ function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig):
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined;
const passwordFile = base.passwordFile?.trim();
let resolvedPassword = base.password?.trim() || envPassword || "";
let resolvedPassword =
normalizeResolvedSecretInputString({
value: base.password,
path: `channels.irc.accounts.${accountId}.nickserv.password`,
}) ||
envPassword ||
"";
if (!resolvedPassword && passwordFile) {
try {
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();

View File

@@ -33,6 +33,7 @@ import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import { normalizeSecretInputString } from "./secret-input.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@@ -326,7 +327,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const password = normalizeSecretInputString(input.password);
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
@@ -364,7 +365,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
homeserver: input.homeserver?.trim(),
userId: input.userId?.trim(),
accessToken: input.accessToken?.trim(),
password: input.password?.trim(),
password: normalizeSecretInputString(input.password),
deviceName: input.deviceName?.trim(),
initialSyncLimit: input.initialSyncLimit,
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { MatrixConfigSchema } from "./config-schema.js";
describe("MatrixConfigSchema SecretInput", () => {
it("accepts SecretRef password at top-level", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
});
expect(result.success).toBe(true);
});
it("accepts SecretRef password on account", () => {
const result = MatrixConfigSchema.safeParse({
accounts: {
work: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "env", provider: "default", id: "MATRIX_WORK_PASSWORD" },
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -1,5 +1,6 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -43,7 +44,7 @@ export const MatrixConfigSchema = z.object({
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().optional(),
password: buildSecretInputSchema().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),

View File

@@ -3,6 +3,7 @@ import {
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput } from "../secret-input.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@@ -106,7 +107,7 @@ export function resolveMatrixAccount(params: {
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const hasPasswordAuth = hasUserId && (hasPassword || hasConfiguredSecretInput(base.password));
const stored = loadMatrixCredentials(process.env, accountId);
const hasStored =
stored && resolved.homeserver

View File

@@ -1,12 +1,17 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js";
import {
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "../../secret-input.js";
import type { CoreConfig } from "../../types.js";
import { loadMatrixSdk } from "../sdk-runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
function clean(value: unknown, path: string): string {
return normalizeResolvedSecretInputString({ value, path }) ?? "";
}
/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
@@ -52,11 +57,23 @@ export function resolveMatrixConfigForAccount(
// nested object inheritance (dm, actions, groups) so partial overrides work.
const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const homeserver =
clean(matrix.homeserver, "channels.matrix.homeserver") ||
clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER");
const userId =
clean(matrix.userId, "channels.matrix.userId") || clean(env.MATRIX_USER_ID, "MATRIX_USER_ID");
const accessToken =
clean(matrix.accessToken, "channels.matrix.accessToken") ||
clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") ||
undefined;
const password =
clean(matrix.password, "channels.matrix.password") ||
clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") ||
undefined;
const deviceName =
clean(matrix.deviceName, "channels.matrix.deviceName") ||
clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") ||
undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
@@ -168,28 +185,36 @@ export async function resolveMatrixAuth(params?: {
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
}),
// Login with password using HTTP API.
const { response: loginResponse, release: releaseLoginResponse } = await fetchWithSsrFGuard({
url: `${resolved.homeserver}/_matrix/client/v3/login`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
}),
},
auditContext: "matrix.login",
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const login = await (async () => {
try {
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
return (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} finally {
await releaseLoginResponse();
}
})();
const accessToken = login.access_token?.trim();
if (!accessToken) {

View File

@@ -3,8 +3,11 @@ import {
addWildcardAllowFrom,
formatResolvedUnresolvedNote,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
promptChannelAccessConfig,
type SecretInput,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
@@ -266,22 +269,24 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
).trim();
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let password: SecretInput | undefined = existing.password;
let userId = existing.userId ?? "";
const existingPasswordConfigured = hasConfiguredSecretInput(existing.password);
const passwordConfigured = () => hasConfiguredSecretInput(password);
if (accessToken || password) {
if (accessToken || passwordConfigured()) {
const keep = await prompter.confirm({
message: "Matrix credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
accessToken = "";
password = "";
password = undefined;
userId = "";
}
}
if (!accessToken && !password) {
if (!accessToken && !passwordConfigured()) {
// Ask auth method FIRST before asking for user ID
const authMode = await prompter.select({
message: "Matrix auth method",
@@ -322,12 +327,25 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
},
}),
).trim();
password = String(
await prompter.text({
message: "Matrix password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const passwordResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "matrix",
credentialLabel: "password",
accountConfigured: Boolean(existingPasswordConfigured),
canUseEnv: Boolean(envPassword?.trim()) && !existingPasswordConfigured,
hasConfigToken: existingPasswordConfigured,
envPrompt: "MATRIX_PASSWORD detected. Use env var?",
keepPrompt: "Matrix password already configured. Keep it?",
inputPrompt: "Matrix password",
preferredEnvVar: "MATRIX_PASSWORD",
});
if (passwordResult.action === "set") {
password = passwordResult.value;
}
if (passwordResult.action === "use-env") {
password = undefined;
}
}
}
@@ -354,7 +372,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
homeserver,
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
password: password,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},

View File

@@ -0,0 +1,19 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk";
import { z } from "zod";
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
export function buildSecretInputSchema() {
return z.union([
z.string(),
z.object({
source: z.enum(["env", "file", "exec"]),
provider: z.string().min(1),
id: z.string().min(1),
}),
]);
}

View File

@@ -1,4 +1,4 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
import type { DmPolicy, GroupPolicy, SecretInput } from "openclaw/plugin-sdk";
export type { DmPolicy, GroupPolicy };
export type ReplyToMode = "off" | "first" | "all";
@@ -58,7 +58,7 @@ export type MatrixConfig = {
/** Matrix access token. */
accessToken?: string;
/** Matrix password (used only to fetch access token). */
password?: string;
password?: SecretInput;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { MattermostConfigSchema } from "./config-schema.js";
describe("MattermostConfigSchema SecretInput", () => {
it("accepts SecretRef botToken at top-level", () => {
const result = MattermostConfigSchema.safeParse({
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
baseUrl: "https://chat.example.com",
});
expect(result.success).toBe(true);
});
it("accepts SecretRef botToken on account", () => {
const result = MattermostConfigSchema.safeParse({
accounts: {
main: {
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN_MAIN" },
baseUrl: "https://chat.example.com",
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -6,6 +6,7 @@ import {
requireOpenAllowFrom,
} from "openclaw/plugin-sdk";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";
const MattermostAccountSchemaBase = z
.object({
@@ -15,7 +16,7 @@ const MattermostAccountSchemaBase = z
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
botToken: buildSecretInputSchema().optional(),
baseUrl: z.string().optional(),
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
oncharPrefixes: z.array(z.string()).optional(),

View File

@@ -4,6 +4,7 @@ import {
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
@@ -101,6 +102,7 @@ function resolveMattermostRequireMention(config: MattermostAccountConfig): boole
export function resolveMattermostAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
allowUnresolvedSecretRef?: boolean;
}): ResolvedMattermostAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
@@ -111,7 +113,12 @@ export function resolveMattermostAccount(params: {
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
const configToken = merged.botToken?.trim();
const configToken = params.allowUnresolvedSecretRef
? normalizeSecretInputString(merged.botToken)
: normalizeResolvedSecretInputString({
value: merged.botToken,
path: `channels.mattermost.accounts.${accountId}.botToken`,
});
const configUrl = merged.baseUrl?.trim();
const botToken = configToken || envToken;
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);

View File

@@ -0,0 +1,25 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import { mattermostOnboardingAdapter } from "./onboarding.js";
describe("mattermost onboarding status", () => {
it("treats SecretRef botToken as configured when baseUrl is present", async () => {
const status = await mattermostOnboardingAdapter.getStatus({
cfg: {
channels: {
mattermost: {
baseUrl: "https://chat.example.test",
botToken: {
source: "env",
provider: "default",
id: "MATTERMOST_BOT_TOKEN",
},
},
},
} as OpenClawConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
});
});

View File

@@ -1,4 +1,11 @@
import type { ChannelOnboardingAdapter, OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
import {
hasConfiguredSecretInput,
promptSingleChannelSecretInput,
type ChannelOnboardingAdapter,
type OpenClawConfig,
type SecretInput,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
listMattermostAccountIds,
@@ -22,31 +29,32 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
);
}
async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{
botToken: string;
baseUrl: string;
}> {
const botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
async function promptMattermostBaseUrl(params: {
prompter: WizardPrompter;
initialValue?: string;
}): Promise<string> {
const baseUrl = String(
await prompter.text({
await params.prompter.text({
message: "Enter Mattermost base URL",
initialValue: params.initialValue,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { botToken, baseUrl };
return baseUrl;
}
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listMattermostAccountIds(cfg).some((accountId) => {
const account = resolveMattermostAccount({ cfg, accountId });
return Boolean(account.botToken && account.baseUrl);
const account = resolveMattermostAccount({
cfg,
accountId,
allowUnresolvedSecretRef: true,
});
const tokenConfigured =
Boolean(account.botToken) || hasConfiguredSecretInput(account.config.botToken);
return tokenConfigured && Boolean(account.baseUrl);
});
return {
channel,
@@ -75,6 +83,7 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
const resolvedAccount = resolveMattermostAccount({
cfg: next,
accountId,
allowUnresolvedSecretRef: true,
});
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
@@ -82,54 +91,34 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
allowEnv &&
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
Boolean(process.env.MATTERMOST_URL?.trim());
const hasConfigValues =
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
const hasConfigToken = hasConfiguredSecretInput(resolvedAccount.config.botToken);
const hasConfigValues = hasConfigToken || Boolean(resolvedAccount.config.baseUrl);
let botToken: string | null = null;
let botToken: SecretInput | null = null;
let baseUrl: string | null = null;
if (!accountConfigured) {
await noteMattermostSetup(prompter);
}
if (canUseEnv && !hasConfigValues) {
const keepEnv = await prompter.confirm({
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
},
},
};
} else {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else if (accountConfigured) {
const keep = await prompter.confirm({
message: "Mattermost credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
const botTokenResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "mattermost",
credentialLabel: "bot token",
accountConfigured,
canUseEnv: canUseEnv && !hasConfigValues,
hasConfigToken,
envPrompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
keepPrompt: "Mattermost bot token already configured. Keep it?",
inputPrompt: "Enter Mattermost bot token",
preferredEnvVar: "MATTERMOST_BOT_TOKEN",
});
if (botTokenResult.action === "keep") {
return { cfg: next, accountId };
}
if (botToken || baseUrl) {
if (botTokenResult.action === "use-env") {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
@@ -138,32 +127,52 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
mattermost: {
...next.channels?.mattermost,
enabled: true,
...(botToken ? { botToken } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
accounts: {
...next.channels?.mattermost?.accounts,
[accountId]: {
...next.channels?.mattermost?.accounts?.[accountId],
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
...(botToken ? { botToken } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
},
},
};
}
return { cfg: next, accountId };
}
botToken = botTokenResult.value;
baseUrl = await promptMattermostBaseUrl({
prompter,
initialValue: resolvedAccount.baseUrl ?? process.env.MATTERMOST_URL?.trim(),
});
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
botToken,
baseUrl,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
accounts: {
...next.channels?.mattermost?.accounts,
[accountId]: {
...next.channels?.mattermost?.accounts?.[accountId],
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
botToken,
baseUrl,
},
},
},
},
};
}
return { cfg: next, accountId };

View File

@@ -0,0 +1,19 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk";
import { z } from "zod";
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
export function buildSecretInputSchema() {
return z.union([
z.string(),
z.object({
source: z.enum(["env", "file", "exec"]),
provider: z.string().min(1),
id: z.string().min(1),
}),
]);
}

View File

@@ -1,4 +1,9 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
SecretInput,
} from "openclaw/plugin-sdk";
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
@@ -17,7 +22,7 @@ export type MattermostAccountConfig = {
/** If false, do not start this Mattermost account. Default: true. */
enabled?: boolean;
/** Bot token for Mattermost. */
botToken?: string;
botToken?: SecretInput;
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
baseUrl?: string;
/**

View File

@@ -1,5 +1,6 @@
import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsGraphMessageUrls,
@@ -46,7 +47,9 @@ type RemoteMediaFetchParams = {
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
const saveMediaBufferMock = vi.fn(async () => ({
id: "saved.png",
path: SAVED_PNG_PATH,
size: Buffer.byteLength(PNG_BUFFER),
contentType: CONTENT_TYPE_IMAGE_PNG,
}));
const readRemoteMediaResponse = async (
@@ -106,19 +109,17 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
throw new Error("too many redirects");
});
const runtimeStub = {
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
media: {
detectMime: detectMimeMock as unknown as PluginRuntime["media"]["detectMime"],
detectMime: detectMimeMock,
},
channel: {
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
fetchRemoteMedia: fetchRemoteMediaMock,
saveMediaBuffer: saveMediaBufferMock,
},
},
} as unknown as PluginRuntime;
});
type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
@@ -440,7 +441,9 @@ const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
beforeDownload: () => {
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_BUFFER),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
},

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { StoredConversationReference } from "./conversation-store.js";
const graphUploadMockState = vi.hoisted(() => ({
uploadAndShareOneDrive: vi.fn(),
@@ -38,7 +39,7 @@ const chunkMarkdownText = (text: string, limit: number) => {
return chunks;
};
const runtimeStub = {
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
channel: {
text: {
chunkMarkdownText,
@@ -47,7 +48,7 @@ const runtimeStub = {
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
});
const createNoopAdapter = (): MSTeamsAdapter => ({
continueConversation: async () => {},

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