Compare commits

..

120 Commits

Author SHA1 Message Date
Tak Hoffman
da116019d3 macos: fix swiftformat lint drift 2026-03-22 11:22:45 -05:00
Tak Hoffman
fd472b0d57 ci: avoid full build in PR test shards 2026-03-22 10:25:44 -05:00
Vincent Koc
4685fc7e77 fix(doctor): align matrix and zalouser allowlist semantics (#52096)
* fix(doctor): align extension allowlist semantics

* fix(doctor): skip generic zalouser group warning
2026-03-22 08:19:24 -07:00
Tak Hoffman
52a0aa0672 ci: build dist before macos tests (#52279)
* ci: hydrate dist before plugin-sdk test lanes

* ci: skip bun-only dist build on PRs

* ci: build dist before macos tests
2026-03-22 09:10:08 -05:00
CharZhou
a07dcfde84 fix: pass clientTools to runEmbeddedAttempt in /v1/responses agent path (#52171)
Merged via squash.

Prepared head SHA: 74519e7da6
Co-authored-by: CharZhou <17255546+CharZhou@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-22 22:05:00 +08:00
Tak Hoffman
7066316db8 ci: hydrate dist before plugin-sdk test lanes (#52267)
* ci: hydrate dist before plugin-sdk test lanes

* ci: skip bun-only dist build on PRs
2026-03-22 08:59:13 -05:00
Luke
ad24fccff5 test(gateway): stabilize suite session-store config (#52193)
* test(gateway): stabilize suite session-store config

* test(gateway): preserve seeded config semantics

* test(gateway): update seeded session store overrides
2026-03-22 19:18:44 +08:00
Bartok9
c70ae1c96e fix(poll-params): treat zero-valued numeric poll params as unset (#52150)
Merged via squash.

Prepared head SHA: 189e695b7c
Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-22 18:39:31 +08:00
Peter Steinberger
67e61acac7 test: refresh unit timing snapshot 2026-03-22 09:35:36 +00:00
Peter Steinberger
b70b7b0d94 test: trim more local test startup overhead 2026-03-22 09:35:36 +00:00
Peter Steinberger
3382ef2724 perf: reduce plugin runtime startup overhead 2026-03-22 09:35:36 +00:00
Bob
aa6b962a3a Discord/ACP: forward abort signals into ACP turns (#52148)
* Discord/ACP: forward abort signals into ACP turns

* ACP: abort queued turns before actor start
2026-03-22 10:04:32 +01:00
Peter Steinberger
8ac3e41cdf test: refresh unit timing snapshot 2026-03-22 07:52:58 +00:00
Peter Steinberger
574cc9de64 test: trim more unit test startup overhead 2026-03-22 07:52:58 +00:00
Peter Steinberger
3cd4978fc2 refactor(agents): unify tool media reply delivery 2026-03-22 00:49:56 -07:00
Peter Steinberger
2d492ab534 test: tighten regression assertions across extension tests 2026-03-22 07:46:07 +00:00
Peter Steinberger
4becbc8b25 test: remove duplicate outbound plugin tests 2026-03-22 07:38:01 +00:00
Peter Steinberger
b4656f193a test: strengthen regression coverage and trim low-value checks 2026-03-22 07:38:01 +00:00
Peter Steinberger
f537ea90ed perf: route hot vitest files to threads 2026-03-22 00:31:17 -07:00
Peter Steinberger
037fa2f8fb test: refresh unit timing snapshot 2026-03-22 07:22:42 +00:00
Peter Steinberger
94ec0d6aeb test: trim plugin-sdk import-heavy startup 2026-03-22 07:22:42 +00:00
Ayaan Zaidi
537115bbdc test: fix channel interaction/media regressions 2026-03-22 11:56:19 +05:30
Peter Steinberger
ec0e4ff218 test: refresh unit timing snapshot 2026-03-22 05:59:46 +00:00
Peter Steinberger
b2f9ab9a1f test: trim import-heavy startup paths 2026-03-22 05:59:46 +00:00
Vincent Koc
041f0b87ec perf(inbound): trim cold startup import graph (#52082)
* perf(inbound): trim cold startup import graph

* chore(reply): drop redundant inline action type import

* fix(inbound): restore warning and maintenance seams

* fix(reply): restore type seam and secure forked transcripts
2026-03-21 22:32:21 -07:00
Vincent Koc
c96a12aeb9 Agents: add per-agent defaults and safe model fallback (#51974)
* Agents: add per-agent defaults and safe model fallback

* Docs: add per-agent thinking/reasoning/fast defaults to config reference and thinking docs

* Format get-reply directives

* Auto-reply: guard agent reasoning defaults

* Docs: update config baseline
2026-03-21 22:27:24 -07:00
Bryan Tegomoh, MD, MPH
f783101735 fix: accept session_status sessionKey=current alias (#39574)
* fix: normalize sessionKey=current in shared session resolution

Move the "current" alias handling from a narrow session_status-only
mapping into the shared session resolution layer so every session tool
(session_status, sessions_history, sessions_send) resolves it
consistently.

Changes:
- Register "current" as a canonical session key in looksLikeSessionKey
  so it is never misclassified as a sessionId
- Normalize "current" to the requester's own session key inside
  resolveSessionReference and resolveInternalSessionKey
- Add "current" normalization in session_status before local store
  lookup via the existing "main" alias scoping
- Add regression tests covering both main-session and cross-agent
  resolution paths

Fixes #39570

* fix: keep session_status current bound to requester

* fix: preserve literal current session targets

* fix: preserve literal current in session_status

* fix: defer current alias in session_status

* fix: scope session_status current to active store (#39574) (thanks @BryanTegomoh)

* fix: preserve literal current session previews (#39574) (thanks @BryanTegomoh)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 10:55:05 +05:30
Peter Steinberger
707eb8e1b3 test: refresh unit timing snapshot 2026-03-22 05:03:10 +00:00
Peter Steinberger
e1854dfbf6 test: trim import-heavy startup paths 2026-03-22 05:03:10 +00:00
Peter Steinberger
8727338372 perf: extract lightweight runtime seams 2026-03-22 05:03:10 +00:00
Vincent Koc
2b210703a3 fix(models): cache models.json readiness for embedded runs (#52077)
* fix(models): cache models.json readiness for embedded runs

* fix(models): harden readiness cache inputs
2026-03-21 21:58:10 -07:00
Tak Hoffman
432e8943ad fix(discord): dedupe inbound message deliveries (#51950)
* fix(discord): dedupe inbound message deliveries

* test(discord): disable debounce in tool-result dispatch spec
2026-03-21 23:55:52 -05:00
Vincent Koc
7a0dacbfba refactor(doctor): extract note emission (#52076)
* refactor(doctor): extract note emission

* test(doctor): cover optional note emission paths
2026-03-21 21:52:14 -07:00
Felippe Mercurio
8790c54635 fix(android): use scheme default port for gateway setup URLs (#43540)
* fix(android): use scheme default port for gateway setup URLs

* test(android): cover gateway endpoint default ports

* fix(android): preserve direct gateway default port

* fix(android): preserve explicit cleartext port display

* fix: preserve Android gateway setup URL ports (#43540) (thanks @fmercurio)

---------

Co-authored-by: clawdia <clawdia@fmercurio.tech>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 10:21:38 +05:30
Tak Hoffman
0f6dbb4390 Fix configure cache ownership for plugin resolvers (#52018)
* fix configure cache ownership

* address web-search cache review

* rekey provider wizard cache

* honor plugin cache opt-outs

* fix cache invalidation gaps

* align plugin snapshot ttl

* refresh snapshot cache keys
2026-03-21 23:41:45 -05:00
Vincent Koc
ec59974a46 refactor(doctor): extract flow finalization (#52067)
* refactor(doctor): extract flow finalization

* test(doctor): pin repair finalization to cfg
2026-03-21 21:27:51 -07:00
Vincent Koc
60f559e217 refactor(doctor): extract matrix sequencing (#52056) 2026-03-21 21:10:48 -07:00
Peter Steinberger
4c9f411f6d test(discord): stabilize tool-result reply tests 2026-03-22 04:07:38 +00:00
Peter Steinberger
7ac312b8fe fix(agents): migrate remaining media artifacts 2026-03-21 21:05:33 -07:00
Goweii
e7e4c68caf Android: update status bar appearance in OpenClawTheme (#51098)
* Android: update status bar appearance in OpenClawTheme

* fix: update Android status bar appearance (#51098) (thanks @goweii)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 09:33:24 +05:30
Peter Steinberger
a2472dc31b refactor(test): dedupe channel startup test setup 2026-03-22 03:50:41 +00:00
Vincent Koc
177136c964 perf(inbound): trim reply startup imports (#51988)
* perf(inbound): narrow reply startup imports

* perf(inbound): trim reply startup imports

* fix(perf): dedupe archive helpers
2026-03-21 20:49:41 -07:00
Vincent Koc
b2380b3ab1 refactor(doctor): extract repair sequencing (#51980)
* refactor(doctor): extract repair sequencing

* fix(doctor): sanitize repair runner notes
2026-03-21 20:43:16 -07:00
Peter Steinberger
89bc66feef refactor(test): dedupe startup channel test helpers 2026-03-22 03:41:25 +00:00
Peter Steinberger
36feecf018 refactor(test): dedupe bluebubbles webhook auth helpers 2026-03-22 03:24:22 +00:00
Kaneki
1e98dbcad3 Android: fix Bitmap memory leaks in PhotosHandler (#41888)
* Android: fix Bitmap memory leaks in PhotosHandler

Bitmaps created by decodeScaledBitmap and intermediate scaled copies
inside encodeJpegUnderBudget were never recycled, leaking native memory
on every photos.latest invocation (up to 20 bitmaps per call).

- latest(): wrap bitmap usage in try/finally to guarantee recycle
- decodeScaledBitmap(): recycle the decoded bitmap after scaling
- encodeJpegUnderBudget(): use try/finally to recycle intermediate
  scaled bitmaps on all exit paths (success, compress failure, and
  cannot-shrink-further early returns)

Made-with: Cursor

* Android: guard decodeScaledBitmap against scale() exceptions

* fix: note Android photos bitmap cleanup (#41888) (thanks @Kaneki-x)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 08:53:26 +05:30
Kaneki
2909d8cd12 Android: fix Bitmap memory leaks in CanvasController snapshots (#41889)
* Android: fix Bitmap memory leaks in CanvasController snapshots

snapshotPngBase64() and snapshotBase64() create bitmaps via
captureBitmap() and scaleForMaxWidth() but never recycle them,
leaking native memory on every canvas snapshot invocation.

Wrap both methods in nested try/finally blocks:
- outer: always recycles the captured bitmap
- inner: recycles the scaled bitmap only when it differs from the
  captured one (scaleForMaxWidth returns `this` when no scaling needed)

Made-with: Cursor

* fix: note Android canvas snapshot bitmap leak in changelog (#41889) (thanks @Kaneki-x)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 08:50:42 +05:30
Kaneki
88da51d91b Android: fix temp file leak in CameraHandler.handleClip (#41890)
* Android: fix temp file leak in CameraHandler.handleClip

When readBytes() throws (IOException, OOM, etc.), the recorded clip
file was never deleted because delete() only ran on the success path.

Move file.delete() into a finally block so the temp file is cleaned up
regardless of whether readBytes() succeeds or fails.

Made-with: Cursor

* fix: Android camera clip cleanup (#41890) (thanks @Kaneki-x)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 08:49:50 +05:30
Ayaan Zaidi
506861efd0 fix: Android contacts search wildcard escaping (#41891) (thanks @Kaneki-x) 2026-03-22 08:48:29 +05:30
kaneki
f7866c1c15 Android: escape SQL LIKE wildcards in contacts search query
The contacts search passed user input directly into a LIKE pattern
without escaping % and _ characters, causing them to act as SQL
wildcards and return incorrect results.

Add an escapeLikePattern() helper that escapes \, %, and _ with a
backslash, and add ESCAPE '\' to the selection clause so SQLite
treats them as literal characters.

Made-with: Cursor
2026-03-22 08:48:29 +05:30
Peter Steinberger
6c4eced494 refactor(test): dedupe bluebubbles webhook helpers 2026-03-22 03:15:48 +00:00
Ayaan Zaidi
eea84bc6ec fix: land Android camera bitmap leak (#41902) (thanks @Kaneki-x) 2026-03-22 08:44:06 +05:30
kaneki
d38561acbe Android: fix Bitmap memory leaks in CameraCaptureManager.snap 2026-03-22 08:44:06 +05:30
Kaneki
d6346aaf63 Android: fix sensor callback race condition in MotionHandler (#43781)
* Android: fix sensor callback race in MotionHandler using tryResume

* call completeResume before unregisterListener to avoid coroutine leak

* replace internal tryResume/completeResume with AtomicBoolean guard

* use CancellableContinuation.resume(value, onCancellation) for cancellation safety

* use non-deprecated resume overload for kotlinx.coroutines 1.10+

* fix: simplify Android motion continuation resume

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 08:41:02 +05:30
Kaneki
e7814f7ba0 Android: fix incomplete JS string escaping in A2UI action status (#43784)
* Android: fix incomplete JS string escaping in A2UI action status

* escape U+2028/U+2029 Unicode line terminators in JS strings

* refactor(android): serialize A2UI action status strings

* fix: serialize Android A2UI action status strings (#43784) (thanks @Kaneki-x)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-22 08:39:53 +05:30
Ayaan Zaidi
b67baae1f6 fix: make permission rationale completion single-shot 2026-03-22 08:34:02 +05:30
kaneki
6db72746fb Android: keep permission dialog cleanup on the main thread 2026-03-22 08:34:02 +05:30
kaneki
518d2dd6a9 Android: harden permission dialogs across activity teardown 2026-03-22 08:34:02 +05:30
Val Alexander
14237aa6c0 refactor(usage): drop empty detail placeholder state (#52013)
* refactor(usage): drop empty detail placeholder state

* docs(changelog): add entry for usage detail cleanup
2026-03-21 21:58:55 -05:00
Peter Steinberger
8e6a4c2d82 perf: narrow discord timeout import seam 2026-03-22 02:42:57 +00:00
Peter Steinberger
b1ab7ba3ac refactor(test): trim bluebubbles webhook fixtures 2026-03-22 02:41:44 +00:00
Peter Steinberger
4f210e98a5 refactor(test): dedupe bluebubbles monitor helpers 2026-03-22 02:18:00 +00:00
Peter Steinberger
7b344b8a8a test: refresh unit timing snapshot 2026-03-22 02:11:43 +00:00
Peter Steinberger
d81772dbc7 test: trim import-heavy startup paths 2026-03-22 02:11:43 +00:00
Peter Steinberger
2cc777539a perf: reduce plugin and memory startup overhead 2026-03-22 02:11:43 +00:00
wilsonIs
1ad3893b39 Control UI: disambiguate duplicate agent session labels (#48209)
* Control UI: disambiguate duplicate agent sessions

* Control UI: avoid prefixed session label collisions

* Control UI: align session defaults typing

---------

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-21 21:11:15 -05:00
Peter Steinberger
17713ec988 refactor(test): dedupe bluebubbles webhook tests 2026-03-22 01:30:22 +00:00
Tak Hoffman
1e4688a584 CI: exempt bad-barnacle from dirty auto-close 2026-03-21 20:23:17 -05:00
Tak Hoffman
d6c05c1941 ci: surface hidden failures in smoke helpers (#51954)
* ci: surface hidden failures in smoke helpers

* changelog: add ci smoke failure-signal note
2026-03-21 20:21:48 -05:00
Vincent Koc
ab38f6471c perf(inbound): narrow reply startup imports (#51943)
* perf(inbound): narrow reply startup imports

* fix(reply): restore activation parsing semantics

Cherry-picked review fixes for activation parsing semantics and native command surface cache invalidation.

* fix(reply): preserve case-insensitive command matching

Cherry-picked review fix to lowercase only the slash-command token in commands-context while preserving argument casing.
2026-03-21 18:18:41 -07:00
Peter Steinberger
f1b2c5639a refactor(test): dedupe startup and nostr test fixtures 2026-03-22 01:12:31 +00:00
Vincent Koc
3775651480 refactor(doctor): extract preview warning collection (#51942)
* refactor(doctor): extract preview warning collection

* fix(doctor): sanitize preview empty allowlist warnings

* test(doctor): cover sanitized preview warnings
2026-03-21 18:11:28 -07:00
Val Alexander
a5309b6f93 feat(usage): improve usage overview styling and localization (#51951)
* feat(usage): add usage page styles and localization

- Introduced a new `usage.css` file for styling the usage overview page.
- Updated `en.ts` localization file to include new usage-related translations.
- Refactored the usage rendering components to utilize the new localization strings for improved user experience.
- Enhanced the `app-render-usage-tab.ts` to better structure the data passed to the rendering function.

* feat(ui): enhance styling and functionality for usage overview and chat components

- Updated `package.json` to include new built dependencies.
- Refined CSS styles across various files to improve UI consistency and accessibility, including adjustments to color themes and layout structures.
- Introduced new responsive grid layouts for usage overview and chat components, enhancing the user experience on different screen sizes.
- Added functionality to hide context notices based on token freshness in chat view.
- Implemented new rendering functions for usage statistics, improving data presentation and user interaction.

* feat(usage): enhance usage overview styling and rendering options

- Added new CSS classes for improved layout and styling of usage insight cards and error lists.
- Updated rendering functions to support customizable class names for usage insight cards and error lists, enhancing flexibility in UI presentation.
- Implemented a wide card layout and specific styling for error lists to improve visual clarity and user experience.

* fix(ui): address review feedback on usage and chat layout

* docs(changelog): add entry for usage UI improvements
2026-03-21 20:07:51 -05:00
Vincent Koc
2b4c3c2057 fix(plugin-sdk): remove relative extension boundary escapes (#51939)
* fix(plugin-sdk): remove relative extension boundary escapes

* Gate new plugin-sdk subpaths on host version

* Add changelog entry for #51939

* Fix local staging for plugin-sdk host version gate

* Raise host floor for line and googlechat plugins

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-21 20:03:18 -05:00
Peter Steinberger
daa042c9a0 test: refresh unit timing snapshot 2026-03-22 00:53:12 +00:00
Peter Steinberger
d0d82ea67b test: trim import-heavy startup paths 2026-03-22 00:53:12 +00:00
Peter Steinberger
8b7f40580d perf: split telegram audit runtime seams 2026-03-22 00:53:12 +00:00
Peter Steinberger
465bd0cef1 fix(test): normalize repo-relative paths on Windows 2026-03-22 00:41:57 +00:00
Vincent Koc
45f84cf639 fix(test): normalize path separators [skip ci] 2026-03-21 17:33:35 -07:00
Tak Hoffman
c449a0a3c1 Add bad-barnacle label to prevent barnacle closures. (#51945) 2026-03-21 19:27:18 -05:00
Peter Steinberger
30ad059da8 refactor(test): dedupe setup wizard helpers 2026-03-22 00:16:31 +00:00
Vincent Koc
85722d4cf2 refactor(doctor): extract legacy and unknown-key steps (#51938) 2026-03-21 16:59:22 -07:00
Vincent Koc
865a90ccab refactor(doctor): extract config mutation state helper (#51935) 2026-03-21 16:45:33 -07:00
Peter Steinberger
57fa59ab92 refactor(test): dedupe setup wizard test helpers 2026-03-21 23:41:54 +00:00
Vincent Koc
6266b842d4 ci(actions): restore audit lane and cache Docker builds (#51933)
* ci(actions): restore secrets audit lane

* ci(actions): restore audits and cache docker builds
2026-03-21 16:36:54 -07:00
Val Alexander
36c6d44eca feat(ui): add multi-session selection and deletion (#51924)
* feat(sessions): implement multi-session deletion and selection functionality

- Added `deleteSessionsAndRefresh` function to handle deletion of multiple sessions.
- Updated session state management to track selected session keys.
- Enhanced UI to support bulk actions for selected sessions, including delete and unselect options.
- Refactored related tests to accommodate new multi-session deletion logic.
- Improved responsiveness of sessions table with new CSS rules for mobile layouts.

* feat(sessions): add page deselection functionality and enhance error handling

- Implemented `onDeselectPage` method to allow deselecting specific pages in the session view.
- Updated `deleteSessionsAndRefresh` to handle multiple deletion errors, storing them in an array and displaying a consolidated error message.
- Enhanced tests to verify the new deselection behavior and updated error handling for session deletions.
2026-03-21 18:33:05 -05:00
Vincent Koc
91f404dc7e refactor(doctor): continue doctor flow extraction (#51920)
* refactor(doctor): extract shared warning formatters

* refactor(doctor): extract provider warning previews

* style(doctor): sort telegram imports
2026-03-21 16:32:11 -07:00
Peter Steinberger
37d5cbe43a test: trim test startup overhead 2026-03-21 23:30:51 +00:00
Peter Steinberger
cf4d301a69 perf: reduce memory startup overhead 2026-03-21 23:30:15 +00:00
Vincent Koc
80441baa15 perf(core): trim provider and inbound startup imports (#51927)
* fix(telegram): fail fast on stuck getUpdates

* perf(core): trim provider and inbound startup imports
2026-03-21 16:20:42 -07:00
Vincent Koc
9854466a04 ci(actions): optimize main CI lanes (#51912)
* ci(actions): optimize main ci lanes

* ci(actions): drop unused secrets lane

* ci(actions): keep build-smoke on prs
2026-03-21 16:15:58 -07:00
Peter Steinberger
9dea537bae fix(cli): clean daemon install imports 2026-03-21 23:14:40 +00:00
Peter Steinberger
a622eecd3b refactor(test): dedupe shared test helpers 2026-03-21 23:07:51 +00:00
Peter Steinberger
29b165e456 refactor: centralize node startup tls planning 2026-03-21 15:58:42 -07:00
Vincent Koc
5b31b3400e refactor(doctor): continue provider and shared extractions (#51905)
* refactor(doctor): extract empty allowlist scanning

* refactor(doctor): extract matrix provider helpers

* refactor(doctor): extract matrix repair orchestration
2026-03-21 15:57:08 -07:00
Vincent Koc
5024967e57 fix(core): trim inbound startup churn (#51899)
* perf(core): narrow sandbox status imports for error helpers

* fix(core): trim inbound startup churn

* fix(auth): expire cached external cli sync state

* test(auth): avoid mtime sleep race in cache test
2026-03-21 15:55:19 -07:00
Vincent Koc
56b6585e2e ci(actions): skip heavy draft pr workflows 2026-03-21 15:50:32 -07:00
Vincent Koc
d88c68fec1 perf(core): narrow sandbox status imports for error helpers (#51897)
* perf(core): narrow sandbox status imports for error helpers

* fix(build): add runtime boundaries for reply understanding

Add missing lazy-load runtime shim files required by get-reply.ts.

* fix(debug): remove duplicate spacing in ingress logs

Use logIngressStage suffix spacing consistently for media and link understanding debug lines.
2026-03-21 15:40:45 -07:00
Vincent Koc
d3731be2f0 fix(config): use discord runtime api defaults 2026-03-21 15:21:42 -07:00
Gustavo Madeira Santana
5b3fce4c85 Outbound: preserve routed audioAsVoice delivery 2026-03-21 15:14:37 -07:00
Vincent Koc
21544f9e53 fix(doctor): harden follow-up repair paths (#51888)
* fix(doctor): harden follow-up repair paths

* fix(doctor): sanitize remaining warning paths
2026-03-21 15:11:01 -07:00
Vincent Koc
825d82b5c9 test(guardrails): use telegram public api in outbound targets 2026-03-21 15:08:43 -07:00
Vincent Koc
99641f01a5 perf(auth): reduce plugin auth cold-start heap (#51891)
* fix(test): recycle unit-fast ci batches

* refactor(config): narrow discord timeout import

* test(outbound): lighten target plugin stubs

* refactor(auth): narrow env api key resolution

* docs(auth): restore anthropic vertex sentinel comment

* refactor(auth): isolate console sanitizer
2026-03-21 15:07:08 -07:00
Vincent Koc
805aaa4ee8 fix(agents): avoid model catalog startup tax on telegram replies 2026-03-21 15:03:55 -07:00
Vincent Koc
5069c771e7 fix(test): recycle unit-fast CI batches (#51884)
* fix(test): recycle unit-fast ci batches

* refactor(config): narrow discord timeout import

* test(outbound): lighten target plugin stubs

* refactor(auth): narrow env api key resolution

* docs(auth): restore anthropic vertex sentinel comment
2026-03-21 14:56:29 -07:00
Vincent Koc
039ea5998e refactor(doctor): continue extracting shared doctor helpers (#51876)
* refactor(doctor): extract exec safe-bin helpers

* refactor(doctor): extract legacy tools-by-sender helpers

* refactor(doctor): extract default account warnings

* refactor(doctor): reuse canonical config path helpers
2026-03-21 14:28:14 -07:00
Bob
c2634b5e40 Agents: raise default timeout to 48h (#51874) 2026-03-21 21:54:46 +01:00
Peter Steinberger
4229ffe2b9 perf: reduce runtime and test startup overhead 2026-03-21 20:18:16 +00:00
Zhenye Dong
80959219ce fix(update): make up-to-date package status explicit (#51409)
Merged via squash.

Prepared head SHA: 75aba35882
Co-authored-by: dongzhenye <5765843+dongzhenye@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-21 17:17:14 -03:00
Peter Steinberger
bfcfc17a8b refactor: tighten plugin sdk entry surface 2026-03-21 20:08:01 +00:00
Peter Steinberger
c29ba9d21a fix(docs): handle anchors in docs link audit 2026-03-21 19:58:36 +00:00
Bob
8cac327c19 ACP: recover hung bound turns (#51816)
* ACP: add hung-turn starvation repro

* ACP: recover hung bound turns

* ACP: preserve timed-out session handles

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-21 20:54:30 +01:00
samzong
5c05347d11 fix(compaction): make compaction guard content-aware to prevent false cancellations in heartbeat sessions (#42119)
Merged via squash.

Prepared head SHA: 3429643315
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-21 12:27:35 -07:00
Menglin Li
ef7a5c3546 fix: use content hash for memory flush dedup instead of compactionCount (#30115) (#34222)
Merged via squash.

Prepared head SHA: bce6f0bda0
Co-authored-by: lml2468 <39320777+lml2468@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-21 12:06:25 -07:00
Matthew19990919
fb50c98d67 fix(tts): add matrix to VOICE_BUBBLE_CHANNELS (#37080)
Merged via squash.

Prepared head SHA: 89ca0355d4
Co-authored-by: Matthew19990919 <504525675@qq.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-21 11:29:53 -07:00
Jari Mustonen
fd2b3ed6af feat(memory): pluggable system prompt section for memory plugins (#40126)
Merged via squash.

Prepared head SHA: 5228d1937f
Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-21 11:20:45 -07:00
Peter Steinberger
ebfc5f8240 fix: detect nvm services from installed command (#51146) 2026-03-21 11:06:31 -07:00
Peter Steinberger
40f5305cd2 fix: handle Linux nvm CA env before startup (#51146) (thanks @GodsBoy) 2026-03-21 11:06:31 -07:00
scoootscooob
d6367c2c55 refactor: route Telegram runtime through plugin sdk (#51772) 2026-03-21 10:24:56 -07:00
scoootscooob
c0e482f4bd refactor: route iMessage runtime through plugin sdk (#51770) 2026-03-21 10:21:20 -07:00
672 changed files with 26841 additions and 20079 deletions

View File

@@ -30,7 +30,9 @@ runs:
for deepen_by in 25 100 300; do
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF" || true
if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Resolved base commit after deepening: $BASE_SHA"
exit 0
@@ -38,7 +40,9 @@ runs:
done
echo "Base commit still missing; fetching full history for $FETCH_REF."
git fetch --no-tags origin "$FETCH_REF" || true
if ! git fetch --no-tags origin "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Resolved base commit after full ref fetch: $BASE_SHA"
exit 0

View File

@@ -63,7 +63,7 @@ runs:
- name: Setup Bun
if: inputs.install-bun == 'true'
uses: oven-sh/setup-bun@v2.1.3
uses: oven-sh/setup-bun@v2.2.0
with:
bun-version: "1.3.9"

View File

@@ -398,11 +398,13 @@ jobs:
const invalidLabel = "invalid";
const spamLabel = "r: spam";
const dirtyLabel = "dirty";
const badBarnacleLabel = "bad-barnacle";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
if (pullRequest) {
if (labelSet.has(dirtyLabel)) {
// `bad-barnacle` exempts PRs that Barnacle incorrectly marked dirty.
if (labelSet.has(dirtyLabel) && !labelSet.has(badBarnacleLabel)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,

View File

@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -16,6 +17,7 @@ jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
@@ -183,8 +185,8 @@ jobs:
run: pnpm release:check
checks:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
needs: [docs-scope, changed-scope, build-artifacts]
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
@@ -221,7 +223,9 @@ jobs:
cache_key_suffix: "node22"
command: |
pnpm build
pnpm test
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
steps:
@@ -259,6 +263,17 @@ jobs:
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
fi
- name: Download dist artifact
if: github.event_name == 'push' && matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Build dist
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
run: pnpm build:plugin-sdk:test-dist
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
run: ${{ matrix.command }}
@@ -404,8 +419,8 @@ jobs:
build-smoke:
name: "build-smoke"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
needs: [docs-scope, changed-scope, build-artifacts]
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -419,7 +434,15 @@ jobs:
install-bun: "false"
use-sticky-disk: "false"
- name: Download dist artifact
if: github.event_name == 'push'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Build dist
if: github.event_name != 'push'
run: pnpm build
- name: Smoke test CLI launcher help
@@ -481,6 +504,7 @@ jobs:
run: python -m pytest -q skills
secrets:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -557,8 +581,8 @@ jobs:
run: pre-commit run --all-files pnpm-audit-prod
checks-windows:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true'
needs: [docs-scope, changed-scope, build-artifacts]
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 45
env:
@@ -678,6 +702,17 @@ jobs:
if: matrix.task == 'test'
run: pnpm canvas:a2ui:bundle
- name: Download dist artifact
if: github.event_name == 'push' && matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Build dist (Windows)
if: github.event_name != 'push' && matrix.task == 'test'
run: pnpm build:plugin-sdk:test-dist
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -700,6 +735,9 @@ jobs:
with:
install-bun: "false"
- name: Build dist (macOS)
run: pnpm build:plugin-sdk:test-dist
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:

View File

@@ -159,6 +159,8 @@ jobs:
with:
context: .
platforms: linux/amd64
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
@@ -171,6 +173,8 @@ jobs:
with:
context: .
platforms: linux/amd64
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
build-args: |
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}
@@ -272,6 +276,8 @@ jobs:
with:
context: .
platforms: linux/arm64
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
provenance: false
@@ -284,6 +290,8 @@ jobs:
with:
context: .
platforms: linux/arm64
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
build-args: |
OPENCLAW_VARIANT=slim
tags: ${{ steps.tags.outputs.slim }}

View File

@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
@@ -15,6 +16,7 @@ env:
jobs:
docs-scope:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
@@ -37,7 +39,7 @@ jobs:
install-smoke:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI

View File

@@ -8,6 +8,7 @@ on:
- Dockerfile.sandbox-common
- scripts/sandbox-common-setup.sh
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
paths:
- Dockerfile.sandbox
- Dockerfile.sandbox-common
@@ -22,6 +23,7 @@ env:
jobs:
sandbox-common-smoke:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout

View File

@@ -42,7 +42,7 @@ jobs:
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
exempt-all-assignees: true
@@ -98,7 +98,7 @@ jobs:
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
exempt-all-assignees: true

View File

@@ -61,9 +61,16 @@ Docs: https://docs.openclaw.ai
- Models/GitHub Copilot: allow forward-compat dynamic model ids without code updates, while preserving configured provider and per-model overrides for those synthetic models. (#51325) Thanks @fuller-stack-dev.
- Agents/compaction: notify users when followup auto-compaction starts and finishes, keeping those notices out of TTS and preserving reply threading for the real assistant reply. (#38805) Thanks @zidongdesign.
- Models/OpenAI: switch the default OpenAI setup model to `openai/gpt-5.4`, keep Codex on `openai-codex/gpt-5.4`, and centralize OpenAI chat, image, TTS, transcription, and embedding defaults in one shared module so future default-model updates stay low-churn. Thanks @vincentkoc.
- Memory/plugins: let the active memory plugin register its own system-prompt section while preserving cache-clear and snapshot-load prompt isolation. (#40126) Thanks @jarimustonen.
- Control UI/usage: improve usage overview styling, localization, and responsive chat/context-notice presentation, including safer theme color handling and unclipped usage-header menus. (#51951) Thanks @BunsDev.
- Agents: add per-agent thinking/reasoning/fast defaults and auto-revert disallowed model overrides to the agent's default selection. Thanks @xuanmingguo and @vincentkoc.
- Control UI/usage: drop the empty session-detail placeholder card so the usage view stays single-column until a real session detail panel is selected. (#52013) Thanks @BunsDev.
### Fixes
- Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit.
- Gateway/Linux: auto-detect nvm-managed Node TLS CA bundle needs before CLI startup and refresh installed services that are missing `NODE_EXTRA_CA_CERTS`. (#51146) Thanks @GodsBoy.
- Android/pairing: resolve portless secure setup URLs to `443` while preserving direct cleartext gateway defaults and explicit `:80` manual endpoints in onboarding. (#43540) Thanks @fmercurio.
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
@@ -84,6 +91,10 @@ Docs: https://docs.openclaw.ai
- CLI/configure: clarify fresh-setup memory-search warnings so they say semantic recall needs at least one embedding provider, and scope the initial model allowlist picker to the provider selected in configure. Thanks @vincentkoc.
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
- Agents/Telegram: avoid rebuilding the full model catalog on ordinary inbound replies so Telegram message handling no longer pays multi-second core startup latency before reply generation. Thanks @vincentkoc.
- Agents/inbound: lazy-load media and link understanding for plain-text turns and cache synced auth stores by auth-file state so ordinary inbound replies avoid unnecessary startup churn. Thanks @vincentkoc.
- Telegram/polling: hard-timeout stuck `getUpdates` requests so wedged network paths fail over sooner instead of waiting for the polling stall watchdog. Thanks @vincentkoc.
- Agents/models: cache `models.json` readiness by config and auth-file state so embedded runner turns stop paying repeated model-catalog startup work before replies. Thanks @vincentkoc.
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc.
@@ -100,6 +111,7 @@ Docs: https://docs.openclaw.ai
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
- Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc.
- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc.
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
@@ -110,8 +122,11 @@ Docs: https://docs.openclaw.ai
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Android/canvas: serialize A2UI action-status event strings before evaluating WebView JS, so action ids and multiline errors do not break the callback dispatch. (#43784) Thanks @Kaneki-x.
- Android/camera: recycle intermediate and final snap bitmaps in `camera.snap` so repeated captures do not leak native image memory. (#41902) Thanks @Kaneki-x.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus.
- CI/onboarding smoke: surface `ensure-base-commit` fetch failures as workflow warnings and fail the onboarding Docker smoke when expected setup prompts drift instead of continuing silently. Thanks @Takhoffman.
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
@@ -131,6 +146,7 @@ Docs: https://docs.openclaw.ai
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0.
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Plugins/runtime barrels: route bundled extension runtime imports through public `openclaw/plugin-sdk/*` subpaths and block relative cross-package escapes so packaged extensions stop depending on monorepo-only relative paths. (#51939) Thanks @vincentkoc.
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
@@ -170,6 +186,7 @@ Docs: https://docs.openclaw.ai
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
- Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500) Thanks @joshavant.
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
### Fixes
@@ -187,6 +204,7 @@ Docs: https://docs.openclaw.ai
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
- Messages/polls: treat zero-valued poll params on `message.send` as unset defaults while keeping non-zero poll params on the poll validation path. (#52150) Fixes #52118. Thanks @Bartok9.
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
@@ -204,14 +222,21 @@ Docs: https://docs.openclaw.ai
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
- make `openclaw update status` explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscoob.
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
- Config/startup: keep bundled web-search allowlist compatibility on a lightweight manifest path so config validation no longer pulls bundled web-search registry imports into startup, while still avoiding accidental auto-allow of config-loaded override plugins. (#51574) Thanks @RichardCao.
- Gateway/chat.send: persist uploaded image references across reloads and compaction without delaying first-turn dispatch or double-submitting the same image to vision models. (#51324) Thanks @fuller-stack-dev.
- Plugins/runtime state: share plugin-facing infra singleton state across duplicate module graphs and keep session-binding adapter ownership stable until the active owner unregisters. (#50725) thanks @huntharo.
- Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf.
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
- Agents/memory flush: keep transcript-hash dedup active across memory-flush fallback retries so a write-then-throw flush attempt cannot append duplicate `MEMORY.md` entries before the fallback cycle completes. (#34222) Thanks @lml2468.
- make `openclaw update status` explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
- Android/canvas: recycle captured and scaled snapshot bitmaps so repeated canvas snapshots do not leak native image memory. (#41889) Thanks @Kaneki-x.
- Android/theme: switch status bar icon contrast with the active system theme so Android light mode no longer leaves unreadable light icons over the app header. (#51098) Thanks @goweii.
- Discord/ACP: forward worker abort signals into ACP turns so timed-out Discord jobs cancel the running turn instead of silently leaving the bound ACP session working in the background.
- Gateway/openresponses: preserve assistant commentary and session continuity across hosted-tool `/v1/responses` turns, and emit streamed tool-call payloads before finalization so client tool loops stay resumable. (#52171) Thanks @CharZhou.
### Breaking
@@ -226,6 +251,8 @@ Docs: https://docs.openclaw.ai
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaws local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
## 2026.3.13
@@ -406,6 +433,7 @@ Docs: https://docs.openclaw.ai
- Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec.
- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28.
- Agents/compaction: treat markup-wrapped heartbeat boilerplate as non-meaningful session history when deciding whether to compact, so heartbeat-only sessions no longer keep compaction alive due to wrapper formatting. (#42119) thanks @samzong.
## 2026.3.11
@@ -541,6 +569,8 @@ Docs: https://docs.openclaw.ai
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
- Android/camera clip cleanup: delete temporary clip files even when `readBytes()` fails so failed clip captures do not leak cache storage. (#41890) Thanks @Kaneki-x.
- Android/photos: recycle decoded and intermediate bitmaps in `photos.latest` so repeated photo fetches stop leaking native memory. (#41888) Thanks @Kaneki-x.
### Security

View File

@@ -4,6 +4,8 @@ import android.content.pm.PackageManager
import android.content.Intent
import android.Manifest
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.appcompat.app.AlertDialog
import androidx.activity.ComponentActivity
@@ -11,17 +13,21 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester(private val activity: ComponentActivity) {
private val mutex = Mutex()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val launcher: ActivityResultLauncher<Array<String>> =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
@@ -86,32 +92,84 @@ class PermissionRequester(private val activity: ComponentActivity) {
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
withContext(Dispatchers.Main) {
if (activity.isFinishing || activity.isDestroyed) {
return@withContext false
}
suspendCancellableCoroutine { cont ->
AlertDialog.Builder(activity)
.setTitle("Permission required")
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
.setOnCancelListener { cont.resume(false) }
.show()
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
val finished = AtomicBoolean(false)
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: Boolean?) {
if (!finished.compareAndSet(false, true)) return
removeObserver()
dialog?.dismiss()
if (result != null) {
cont.resume(result)
}
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
finish(false)
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
cont.invokeOnCancellation {
mainHandler.post {
finish(null)
}
}
dialog =
AlertDialog.Builder(activity)
.setTitle("Permission required")
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton("Continue") { _, _ -> finish(true) }
.setNegativeButton("Not now") { _, _ -> finish(false) }
.setOnCancelListener { finish(false) }
.show()
}
}
private fun showSettingsDialog(permissions: List<String>) {
AlertDialog.Builder(activity)
.setTitle("Enable permission in Settings")
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
private suspend fun showSettingsDialog(permissions: List<String>) =
withContext(Dispatchers.Main) {
if (activity.isFinishing || activity.isDestroyed) return@withContext
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
.setNegativeButton("Cancel", null)
.show()
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
removeObserver()
dialog?.dismiss()
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
dialog =
AlertDialog.Builder(activity)
.setTitle("Enable permission in Settings")
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
if (activity.isFinishing || activity.isDestroyed) return@setPositiveButton
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
}
.setNegativeButton("Cancel", null)
.setOnDismissListener { removeObserver() }
.show()
}
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }

View File

@@ -121,42 +121,48 @@ class CameraCaptureManager(private val context: Context) {
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
.toInt()
.coerceAtLeast(1)
rotated.scale(maxWidth, h)
val s = rotated.scale(maxWidth, h)
if (s !== rotated) rotated.recycle()
s
} else {
rotated
}
val maxPayloadBytes = 5 * 1024 * 1024
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = scaled.width,
initialHeight = scaled.height,
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
maxBytes = maxEncodedBytes,
encode = { width, height, q ->
val bitmap =
if (width == scaled.width && height == scaled.height) {
scaled
} else {
scaled.scale(width, height)
try {
val maxPayloadBytes = 5 * 1024 * 1024
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = scaled.width,
initialHeight = scaled.height,
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
maxBytes = maxEncodedBytes,
encode = { width, height, q ->
val bitmap =
if (width == scaled.width && height == scaled.height) {
scaled
} else {
scaled.scale(width, height)
}
val out = ByteArrayOutputStream()
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
if (bitmap !== scaled) bitmap.recycle()
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
val out = ByteArrayOutputStream()
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
if (bitmap !== scaled) bitmap.recycle()
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
if (bitmap !== scaled) {
bitmap.recycle()
}
out.toByteArray()
},
if (bitmap !== scaled) {
bitmap.recycle()
}
out.toByteArray()
},
)
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
)
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
)
} finally {
scaled.recycle()
}
}
@SuppressLint("MissingPermission")

View File

@@ -134,9 +134,11 @@ class CameraHandler(
}
val bytes = withContext(Dispatchers.IO) {
val b = filePayload.file.readBytes()
filePayload.file.delete()
b
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")

View File

@@ -180,27 +180,41 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
val (compressFormat, compressQuality) =
when (format) {
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
val (compressFormat, compressQuality) =
when (format) {
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
}
scaled.compress(compressFormat, compressQuality, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
scaled.compress(compressFormat, compressQuality, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
bmp.recycle()
}
}
private suspend fun WebView.captureBitmap(): Bitmap =

View File

@@ -76,8 +76,8 @@ private object SystemContactsDataSource : ContactsDataSource {
selection = null
selectionArgs = null
} else {
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
selectionArgs = arrayOf("%${request.query}%")
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
}
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
resolver.query(
@@ -247,6 +247,9 @@ private object SystemContactsDataSource : ContactsDataSource {
}
}
private fun escapeLikePattern(pattern: String): String =
pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
resolver = resolver,

View File

@@ -10,6 +10,7 @@ import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.time.Instant
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
@@ -18,7 +19,6 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
@@ -142,19 +142,18 @@ private object SystemMotionDataSource : MotionDataSource {
val averageDelta: Double,
)
@OptIn(InternalCoroutinesApi::class)
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
val sample =
withTimeoutOrNull(1200L) {
suspendCancellableCoroutine<Float?> { cont ->
var resumed = false
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (resumed) return
val value = event?.values?.firstOrNull()
resumed = true
val token = cont.tryResume(value) ?: return
cont.completeResume(token)
sensorManager.unregisterListener(this)
cont.resume(value)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
@@ -162,8 +161,7 @@ private object SystemMotionDataSource : MotionDataSource {
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
sensorManager.unregisterListener(listener)
resumed = true
cont.resume(null)
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
@@ -172,6 +170,7 @@ private object SystemMotionDataSource : MotionDataSource {
return sample?.toInt()?.takeIf { it >= 0 }
}
@OptIn(InternalCoroutinesApi::class)
private suspend fun readAccelerometerSample(
sensorManager: SensorManager,
sensor: Sensor,
@@ -181,7 +180,6 @@ private object SystemMotionDataSource : MotionDataSource {
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
var count = 0
var sumDelta = 0.0
var resumed = false
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
@@ -195,15 +193,14 @@ private object SystemMotionDataSource : MotionDataSource {
).toDouble()
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
resumed = true
sensorManager.unregisterListener(this)
cont.resume(
AccelerometerSample(
samples = count,
averageDelta = if (count == 0) 0.0 else sumDelta / count,
),
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
val result = AccelerometerSample(
samples = count,
averageDelta = sumDelta / count,
)
val token = cont.tryResume(result) ?: return
cont.completeResume(token)
sensorManager.unregisterListener(this)
}
}
@@ -211,8 +208,7 @@ private object SystemMotionDataSource : MotionDataSource {
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
resumed = true
cont.resume(null)
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }

View File

@@ -71,17 +71,22 @@ private object SystemPhotosDataSource : PhotosDataSource {
for (row in rows) {
if (remainingBudget <= 0) break
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
if (encoded.base64.length > remainingBudget) break
remainingBudget -= encoded.base64.length
out +=
EncodedPhotoPayload(
format = "jpeg",
base64 = encoded.base64,
width = encoded.width,
height = encoded.height,
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
)
try {
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
if (encoded == null) continue
if (encoded.base64.length > remainingBudget) break
remainingBudget -= encoded.base64.length
out +=
EncodedPhotoPayload(
format = "jpeg",
base64 = encoded.base64,
width = encoded.width,
height = encoded.height,
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
)
} finally {
bitmap.recycle()
}
}
return out
}
@@ -159,7 +164,11 @@ private object SystemPhotosDataSource : PhotosDataSource {
if (decoded.width <= maxWidth) return decoded
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
return decoded.scale(maxWidth, targetHeight, true)
return try {
decoded.scale(maxWidth, targetHeight, true)
} finally {
decoded.recycle()
}
}
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
@@ -178,30 +187,36 @@ private object SystemPhotosDataSource : PhotosDataSource {
maxBase64Chars: Int,
): EncodedJpeg? {
var working = bitmap
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
repeat(10) {
val out = ByteArrayOutputStream()
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
if (!ok) return null
val bytes = out.toByteArray()
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
if (base64.length <= maxBase64Chars) {
return EncodedJpeg(
base64 = base64,
width = working.width,
height = working.height,
)
try {
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
repeat(10) {
val out = ByteArrayOutputStream()
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
if (!ok) return null
val bytes = out.toByteArray()
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
if (base64.length <= maxBase64Chars) {
return EncodedJpeg(
base64 = base64,
width = working.width,
height = working.height,
)
}
if (jpegQuality > 35) {
jpegQuality = max(25, jpegQuality - 15)
return@repeat
}
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
if (nextWidth >= working.width) return null
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
val previous = working
working = working.scale(nextWidth, nextHeight, true)
if (previous !== bitmap) previous.recycle()
}
if (jpegQuality > 35) {
jpegQuality = max(25, jpegQuality - 15)
return@repeat
}
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
if (nextWidth >= working.width) return null
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
working = working.scale(nextWidth, nextHeight, true)
return null
} finally {
if (working !== bitmap) working.recycle()
}
return null
}
}

View File

@@ -58,9 +58,12 @@ object OpenClawCanvasA2UIAction {
}
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
val err = jsonStringLiteral(error ?: "")
val okLiteral = if (ok) "true" else "false"
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
val idLiteral = jsonStringLiteral(actionId)
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: ${idLiteral}, ok: ${okLiteral}, error: ${err} } }));"
}
private fun jsonStringLiteral(raw: String): String =
JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
}

View File

@@ -97,8 +97,25 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"wss", "https" -> true
else -> true
}
val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
val defaultPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 18789
else -> 443
}
val displayPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 80
else -> 443
}
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val displayUrl =
if (port == displayPort && defaultPort == displayPort) {
"${if (tls) "https" else "http"}://$host"
} else {
"${if (tls) "https" else "http"}://$host:$port"
}
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
}

View File

@@ -1,13 +1,17 @@
package ai.openclaw.app.ui
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@Composable
fun OpenClawTheme(content: @Composable () -> Unit) {
@@ -16,6 +20,15 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, window.decorView)
.isAppearanceLightStatusBars = !isDark
}
}
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
MaterialTheme(colorScheme = colorScheme, content = content)
}

View File

@@ -46,4 +46,18 @@ class OpenClawCanvasA2UIActionTest {
js,
)
}
@Test
fun jsDispatchA2uiStatusQuotesControlCharacters() {
val js =
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = "a1\n\u2028\"",
ok = false,
error = "parse failed\n\t\u2029\\",
)
assertEquals(
"window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\\n\\u2028\\\"\", ok: false, error: \"parse failed\\n\\t\\u2029\\\\\" } }));",
js,
)
}
}

View File

@@ -4,8 +4,86 @@ import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class GatewayConfigResolverTest {
@Test
fun parseGatewayEndpointUsesDefaultTlsPortForBareWssUrls() {
val parsed = parseGatewayEndpoint("wss://gateway.example")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 443,
tls = true,
displayUrl = "https://gateway.example",
),
parsed,
)
}
@Test
fun parseGatewayEndpointUsesDefaultCleartextPortForBareWsUrls() {
val parsed = parseGatewayEndpoint("ws://gateway.example")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 18789,
tls = false,
displayUrl = "http://gateway.example:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() {
val parsed = parseGatewayEndpoint("https://gateway.example:443")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 443,
tls = true,
displayUrl = "https://gateway.example",
),
parsed,
)
}
@Test
fun parseGatewayEndpointKeepsExplicitNonDefaultPortInDisplayUrl() {
val parsed = parseGatewayEndpoint("http://gateway.example:8080")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 8080,
tls = false,
displayUrl = "http://gateway.example:8080",
),
parsed,
)
}
@Test
fun parseGatewayEndpointKeepsExplicitCleartextPort80InDisplayUrl() {
val parsed = parseGatewayEndpoint("http://gateway.example:80")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 80,
tls = false,
displayUrl = "http://gateway.example:80",
),
parsed,
)
}
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode =

View File

@@ -783,7 +783,7 @@ extension AppState {
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.updatedRemoteGatewayConfig(
updatedRemoteGatewayConfig(
current: current,
transport: transport,
remoteUrl: remoteUrl,
@@ -791,7 +791,8 @@ extension AppState {
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).remote
remoteTokenDirty: remoteTokenDirty
).remote
}
static func _testSyncedGatewayRoot(
@@ -804,7 +805,7 @@ extension AppState {
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.syncedGatewayRoot(
syncedGatewayRoot(
currentRoot: currentRoot,
connectionMode: connectionMode,
remoteTransport: remoteTransport,
@@ -812,7 +813,8 @@ extension AppState {
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).root
remoteTokenDirty: remoteTokenDirty
).root
}
}
#endif

View File

@@ -8,8 +8,8 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "openclawCanvasA2UIAction"
static let allMessageNames = [messageName]
// Compatibility helper for debug/test shims. Runtime dispatch remains
// limited to in-app canvas schemes in `didReceive`.
// Compatibility helper for debug/test shims.
// Runtime dispatch remains limited to in-app canvas schemes in `didReceive`.
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false

View File

@@ -54,11 +54,13 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// expose unattended deep-link credentials to page JavaScript.
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let allowedSchemesJSON = (
try? String(
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
encoding: .utf8)
) ?? "[]"
let allowedSchemesJSON =
(
try? String(
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
encoding: .utf8
)
) ?? "[]"
let bridgeScript = """
(() => {
try {

View File

@@ -147,7 +147,8 @@ extension CronJobEditor {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]
)
}
return ["kind": "every", "everyMs": ms]
case .cron:
@@ -265,7 +266,11 @@ extension CronJobEditor {
}
var effectiveSessionTargetRaw: String {
if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty {
if self.sessionTarget == .isolated,
let preserved = self.preservedSessionTargetRaw?
.trimmingCharacters(in: .whitespacesAndNewlines),
!preserved.isEmpty
{
return preserved
}
return self.sessionTarget.rawValue

View File

@@ -16,10 +16,10 @@ enum CronCustomSessionTarget: Codable, Equatable {
var rawValue: String {
switch self {
case .predefined(let target):
return target.rawValue
case .session(let id):
return "session:\(id)"
case let .predefined(target):
target.rawValue
case let .session(id):
"session:\(id)"
}
}
@@ -96,20 +96,24 @@ enum CronSchedule: Codable, Equatable {
throw DecodingError.dataCorruptedError(
forKey: .at,
in: container,
debugDescription: "Missing schedule.at")
debugDescription: "Missing schedule.at"
)
case "every":
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs)
)
case "cron":
self = try .cron(
expr: container.decode(String.self, forKey: .expr),
tz: container.decodeIfPresent(String.self, forKey: .tz))
tz: container.decodeIfPresent(String.self, forKey: .tz)
)
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown schedule kind: \(kind)")
debugDescription: "Unknown schedule kind: \(kind)"
)
}
}
@@ -185,12 +189,14 @@ enum CronPayload: Codable, Equatable {
channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)
)
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown payload kind: \(kind)")
debugDescription: "Unknown payload kind: \(kind)"
)
}
}
@@ -328,10 +334,10 @@ struct CronJob: Identifiable, Codable, Equatable {
/// predefined enum.
var sessionTarget: CronSessionTarget {
switch self.parsedSessionTarget {
case .predefined(let target):
return target
case let .predefined(target):
target
case .session:
return .isolated
.isolated
}
}
@@ -345,8 +351,8 @@ struct CronJob: Identifiable, Codable, Equatable {
return nil
case .predefined(.isolated), .predefined(.current):
return "cron:\(self.id)"
case .session(let id):
return id
case let .session(id):
id
}
}
@@ -361,7 +367,7 @@ struct CronJob: Identifiable, Codable, Equatable {
var displayName: String {
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed
trimmed.isEmpty ? "Untitled job" : trimmed
}
var nextRunDate: Date? {

View File

@@ -113,27 +113,29 @@ final class ExecApprovalsGatewayPrompter {
let mode = AppStateStore.shared.connectionMode
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
// Read-only resolve to avoid disk writes on the MainActor
let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let shouldAsk = Self.shouldAsk(security: security, ask: ask)
let canPresent = shouldAsk && Self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: Self.lastInputSeconds(),
thresholdSeconds: 120)
thresholdSeconds: 120
)
return PresentationDecision(
shouldAsk: shouldAsk,
canPresent: canPresent,
security: security,
askFallback: approvals.agent.askFallback,
allowlist: approvals.allowlist)
allowlist: approvals.allowlist
)
}
private static func fallbackDecision(

View File

@@ -147,7 +147,10 @@ actor MacNodeBrowserProxy {
}
if method != "GET", let body = params.body {
request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
request.httpBody = try JSONSerialization.data(
withJSONObject: body.foundationValue,
options: [.fragmentsAllowed]
)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}

View File

@@ -337,7 +337,6 @@ extension OnboardingView {
self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking
}
@ViewBuilder
private func remoteConnectionSection() -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
@@ -440,7 +439,7 @@ extension OnboardingView {
private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View {
let promptStyle = Self.remoteAuthPromptStyle(for: issue)
return HStack(alignment: .top, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: promptStyle.systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(promptStyle.tint)
@@ -503,17 +502,17 @@ extension OnboardingView {
{
switch issue {
case .tokenRequired:
return ("key.fill", .orange)
("key.fill", .orange)
case .tokenMismatch:
return ("exclamationmark.triangle.fill", .orange)
("exclamationmark.triangle.fill", .orange)
case .gatewayTokenNotConfigured:
return ("wrench.and.screwdriver.fill", .orange)
("wrench.and.screwdriver.fill", .orange)
case .setupCodeExpired:
return ("qrcode.viewfinder", .orange)
("qrcode.viewfinder", .orange)
case .passwordRequired:
return ("lock.slash.fill", .orange)
("lock.slash.fill", .orange)
case .pairingRequired:
return ("link.badge.plus", .orange)
("link.badge.plus", .orange)
}
}

View File

@@ -155,7 +155,9 @@ enum RemoteGatewayProbe {
return .failed("Set a gateway URL first")
}
guard self.isValidWsUrl(trimmedUrl) else {
return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
return .failed(
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)"
)
}
} else {
let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -173,7 +175,8 @@ enum RemoteGatewayProbe {
command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
timeout: 8
)
guard sshResult.ok else {
return .failed(self.formatSSHFailure(sshResult, target: settings.target))
}
@@ -221,7 +224,10 @@ enum RemoteGatewayProbe {
trimmed.localizedCaseInsensitiveContains("host key verification failed")
{
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
return """
SSH check failed: Host key verification failed. Remove the old key with \
ssh-keygen -R \(host) and try again.
"""
}
if let trimmed, !trimmed.isEmpty {
if let message = response.message, message.hasPrefix("exit ") {

View File

@@ -26,12 +26,16 @@ enum TalkModeGatewayConfigParser {
envApiKey: String?
) -> TalkModeGatewayConfigState {
let talk = snapshot.config?["talk"]?.dictionaryValue
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
let selection = TalkConfigParsing.selectProviderConfig(
talk,
defaultProvider: defaultProvider
)
let activeProvider = selection?.provider ?? defaultProvider
let activeConfig = selection?.config
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
fallback: defaultSilenceTimeoutMs
)
let ui = snapshot.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let voice = activeConfig?["voiceId"]?.stringValue
@@ -73,7 +77,8 @@ enum TalkModeGatewayConfigParser {
interruptOnSpeech: interrupt ?? true,
silenceTimeoutMs: silenceTimeoutMs,
apiKey: resolvedApiKey,
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
seamColorHex: rawSeam.isEmpty ? nil : rawSeam
)
}
static func fallback(

View File

@@ -4111,6 +4111,20 @@
"tags": [],
"hasChildren": false
},
{
"path": "agents.list.*.fastModeDefault",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Agent Fast Mode Default",
"help": "Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.",
"hasChildren": false
},
{
"path": "agents.list.*.groupChat",
"kind": "core",
@@ -5226,6 +5240,25 @@
"tags": [],
"hasChildren": false
},
{
"path": "agents.list.*.reasoningDefault",
"kind": "core",
"type": "string",
"required": false,
"enumValues": [
"on",
"off",
"stream"
],
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Agent Reasoning Default",
"help": "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.",
"hasChildren": false
},
{
"path": "agents.list.*.runtime",
"kind": "core",
@@ -6287,6 +6320,29 @@
"tags": [],
"hasChildren": false
},
{
"path": "agents.list.*.thinkingDefault",
"kind": "core",
"type": "string",
"required": false,
"enumValues": [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive"
],
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Agent Thinking Default",
"help": "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
"hasChildren": false
},
{
"path": "agents.list.*.tools",
"kind": "core",

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5563}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5566}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -354,6 +354,7 @@
{"recordType":"path","path":"agents.list.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.agentDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.default","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.fastModeDefault","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Fast Mode Default","help":"Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -463,6 +464,7 @@
{"recordType":"path","path":"agents.list.*.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.params","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.params.*","kind":"core","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.reasoningDefault","kind":"core","type":"string","required":false,"enumValues":["on","off","stream"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Reasoning Default","help":"Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.runtime","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Runtime","help":"Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.","hasChildren":true}
{"recordType":"path","path":"agents.list.*.runtime.acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Runtime","help":"ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.","hasChildren":true}
{"recordType":"path","path":"agents.list.*.runtime.acp.agent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Harness Agent","help":"Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).","hasChildren":false}
@@ -561,6 +563,7 @@
{"recordType":"path","path":"agents.list.*.subagents.model.fallbacks.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.subagents.model.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.subagents.thinking","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.thinkingDefault","kind":"core","type":"string","required":false,"enumValues":["off","minimal","low","medium","high","xhigh","adaptive"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Thinking Default","help":"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.tools","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.tools.allow","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.tools.allow.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

View File

@@ -30,7 +30,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`status`](/cli/status)
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
- [`report`](/cli/report)
- [`gateway`](/cli/gateway)
- [`logs`](/cli/logs)
- [`system`](/cli/system)
@@ -152,10 +151,6 @@ openclaw [--dev] [--profile <name>] <command>
status
health
sessions
report
bug
feature
security
gateway
call
health
@@ -717,27 +712,6 @@ Options:
- `--store <path>`
- `--active <minutes>`
### `report`
Prepare sanitized bug reports, feature requests, and private security report packets for `openclaw/openclaw`.
Subcommands:
- `bug`
- `feature`
- `security`
Shared options:
- `--title <text>`
- `--summary <text>`
- `--json`
- `--markdown`
- `--output <file>`
- `--submit`
- `--yes`
- `--non-interactive`
## Reset / Uninstall
### `reset`

View File

@@ -1,204 +0,0 @@
---
summary: "CLI reference for `openclaw report` (bug reports, feature requests, and private security packets)"
read_when:
- You want to prepare a sanitized GitHub issue draft from local OpenClaw state
- You want to submit a public bug or feature issue with `gh`
- You need a private security report packet instead of a public issue
title: "report"
---
# `openclaw report`
Prepare sanitized reports for `openclaw/openclaw`.
`openclaw report` turns a small amount of user input plus local runtime/config context into:
- public bug report drafts
- public feature request drafts
- private security report packets
Public bug and feature reports can optionally be submitted with `gh`. Security reports never create a public GitHub issue.
## Subcommands
- `openclaw report bug`
- `openclaw report feature`
- `openclaw report security`
## Shared flags
- `--title <text>`: explicit report title
- `--summary <text>`: short summary (used in public reports and as a fallback title)
- `--json`: emit the structured sanitized payload
- `--markdown`: emit only the rendered report body
- `--output <file>`: write the sanitized body to a file
- `--submit`: submit a public bug/feature issue when the report is ready
- `--yes`: skip interactive confirmation for submission
- `--non-interactive`: disable prompts; public submission requires `--yes`
If neither `--json` nor `--markdown` is passed, the default output is a human-readable sanitized preview.
`--yes` only skips the final interactive confirmation. It does not skip draft generation, diagnostics, or probe execution.
## Bug reports
Use `openclaw report bug` for broken behavior, regressions, or operational failures.
Examples:
```bash
openclaw report bug \
--summary "Gateway times out behind mitmproxy" \
--repro "1. Start gateway behind proxy\n2. Send any LLM request" \
--expected "Model responds successfully" \
--actual "Requests fail with timeout" \
--impact "Blocks all LLM traffic"
```
```bash
openclaw report bug \
--summary "Gateway times out behind mitmproxy" \
--repro "1. Start gateway behind proxy\n2. Send any LLM request" \
--expected "Model responds successfully" \
--actual "Requests fail with timeout" \
--impact "Blocks all LLM traffic" \
--probe gateway \
--submit
```
Bug-specific flags:
- `--repro <text>`: steps to reproduce
- `--expected <text>`: expected behavior
- `--actual <text>`: observed behavior
- `--impact <text>`: severity or workflow impact
- `--previous-version <text>`: optional regression context
- `--evidence <text>`: extra evidence to append
- `--additional-information <text>`: broad extra details, clues, timelines, or hypotheses
- `--context <text>`: compatibility alias for `--additional-information`
- `--probe <general|model|channel|gateway|none>`: bounded evidence collection mode
Required fields for a submission-eligible bug report:
- summary
- repro
- expected
- actual
- impact
Auto-collected where available:
- OpenClaw version
- OS/runtime summary
- configured model/provider hints
- a short bounded probe summary when `--probe` is enabled
Probe guidance:
- `general`: runtime summary, proxy env context, gateway/model/channel signals, and one recent sanitized runtime error when available
- `gateway`: gateway reachability, health, and proxy context
- `model`: provider auth overview plus a bounded live model-path check with combined proxy-status output
- `channel`: configured-channel summary plus recent channel/runtime issue hints
## Feature requests
Use `openclaw report feature` for improvements or new capabilities.
Examples:
```bash
openclaw report feature \
--summary "Add a report dry-run flag" \
--problem "Operators want draft output without touching GitHub" \
--solution "Support report --submit only when explicitly requested" \
--impact "Safer issue authoring from scripts"
```
Feature-specific flags:
- `--problem <text>`: problem to solve
- `--solution <text>`: proposed solution
- `--impact <text>`: expected impact
- `--alternatives <text>`: alternatives considered
- `--evidence <text>`: examples or supporting evidence
- `--additional-information <text>`: broad extra details, clues, timelines, or hypotheses
- `--context <text>`: compatibility alias for `--additional-information`
- `--probe <general|model|channel|gateway|none>`: optional bounded evidence collection
Required fields for a submission-eligible feature request:
- summary
- problem
- solution
- impact
## Security reports
Use `openclaw report security` for private vulnerability reports or sensitive disclosures.
Example:
```bash
openclaw report security \
--title "Gateway token exposed in logs" \
--severity high \
--impact "Operator credential disclosure" \
--component "gateway auth logging" \
--reproduction "Run startup flow with verbose logging enabled" \
--demonstrated-impact "Token appears in terminal output" \
--environment "macOS 15.4, OpenClaw 2026.3.x" \
--remediation "Mask auth values before logging"
```
Security-specific flags:
- `--severity <text>`
- `--impact <text>`
- `--component <text>`
- `--reproduction <text>`
- `--demonstrated-impact <text>`
- `--environment <text>`
- `--remediation <text>`
Rules:
- `report security` never calls `gh issue create`
- `--submit` is ignored as a public-issue path and returns a blocked submission status
- terminal output stays private-report-oriented
- use `--output` or `--markdown` to save a private report packet for manual sending
Private route: send completed security reports to `security@openclaw.ai`.
## Redaction and submission behavior
The command sanitizes common sensitive values before rendering output or submitting:
- tokens / bearer values / API keys
- email addresses
- phone numbers
- private user handles
- local user path prefixes such as `/Users/<name>` or `/home/<name>`
For public bug and feature reports:
- `--submit` is required before any GitHub issue is created
- interactive runs ask for confirmation before `gh issue create`
- non-interactive submission requires both `--submit` and `--yes`
- if required fields are missing, the command returns a structured blocked state instead of guessing
- generated report bodies include a short provenance footer noting they were generated via `openclaw report`
## JSON output
`--json` emits a stable sanitized payload with fields such as:
- `kind`
- `title`
- `body`
- `labels`
- `evidence`
- `redactionsApplied`
- `missingFields`
- `submissionEligible`
- `submission`
This is intended for scripting and higher-level automation.

View File

@@ -303,6 +303,20 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
},
},
},
list: [
{
id: "main",
default: true,
thinkingDefault: "high", // per-agent thinking override
reasoningDefault: "on", // per-agent reasoning visibility
fastModeDefault: false, // per-agent fast mode
},
{
id: "quick",
fastModeDefault: true, // this agent always runs fast
thinkingDefault: "off",
},
],
},
tools: {

View File

@@ -1372,6 +1372,9 @@ scripts/sandbox-browser-setup.sh # optional browser image
workspace: "~/.openclaw/workspace",
agentDir: "~/.openclaw/agents/main/agent",
model: "anthropic/claude-opus-4-6", // or { primary, fallbacks }
thinkingDefault: "high", // per-agent thinking level override
reasoningDefault: "on", // per-agent reasoning visibility override
fastModeDefault: false, // per-agent fast mode override
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
identity: {
name: "Samantha",
@@ -1407,6 +1410,9 @@ scripts/sandbox-browser-setup.sh # optional browser image
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.

View File

@@ -133,6 +133,29 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
`OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using `$HOME` before use.
## nvm users: web_fetch TLS failures
If Node.js was installed via **nvm** (not the system package manager), the built-in `fetch()` uses
nvm's bundled CA store, which may be missing modern root CAs (ISRG Root X1/X2 for Let's Encrypt,
DigiCert Global Root G2, etc.). This causes `web_fetch` to fail with `"fetch failed"` on most HTTPS sites.
On Linux, OpenClaw automatically detects nvm and applies the fix in the actual startup environment:
- `openclaw gateway install` writes `NODE_EXTRA_CA_CERTS` into the systemd service environment
- the `openclaw` CLI entrypoint re-execs itself with `NODE_EXTRA_CA_CERTS` set before Node startup
**Manual fix (for older versions or direct `node ...` launches):**
Export the variable before starting OpenClaw:
```bash
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
openclaw gateway run
```
Do not rely on writing only to `~/.openclaw/.env` for this variable; Node reads
`NODE_EXTRA_CA_CERTS` at process startup.
## Related
- [Gateway configuration](/gateway/configuration)

View File

@@ -128,7 +128,7 @@ my-plugin/
**Provider plugin:**
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
id: "my-provider",
@@ -144,7 +144,7 @@ my-plugin/
**Multi-capability plugin** (provider + tool):
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
id: "my-plugin",
@@ -157,8 +157,14 @@ my-plugin/
});
```
Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry`
for everything else. A single plugin can register as many capabilities as needed.
Use `defineChannelPluginEntry` from `plugin-sdk/core` for channel plugins
and `definePluginEntry` from `plugin-sdk/plugin-entry` for everything else.
A single plugin can register as many capabilities as needed.
For chat-style channels, `plugin-sdk/core` also exposes
`createChatChannelPlugin(...)` so you can compose common DM security,
text pairing, reply threading, and attached outbound send results without
wiring each adapter separately.
</Step>
@@ -173,7 +179,7 @@ my-plugin/
```typescript
// Correct: focused subpaths
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
@@ -187,7 +193,8 @@ my-plugin/
<Accordion title="Common subpaths reference">
| Subpath | Purpose |
| --- | --- |
| `plugin-sdk/core` | Plugin entry definitions and base types |
| `plugin-sdk/plugin-entry` | Canonical `definePluginEntry` helper + provider/plugin entry types |
| `plugin-sdk/core` | Channel entry helpers, channel builders, and shared base types |
| `plugin-sdk/channel-setup` | Setup wizard adapters |
| `plugin-sdk/channel-pairing` | DM pairing primitives |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring |

View File

@@ -115,7 +115,8 @@ is a small, self-contained module with a clear purpose and documented contract.
<Accordion title="Full import path table">
| Import path | Purpose | Key exports |
| --- | --- | --- |
| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
| `plugin-sdk/plugin-entry` | Canonical plugin entry helper | `definePluginEntry` |
| `plugin-sdk/core` | Channel entry definitions, channel builders, base types | `defineChannelPluginEntry`, `createChatChannelPlugin` |
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |

View File

@@ -28,8 +28,9 @@ title: "Thinking Levels"
1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message).
3. Global default (`agents.defaults.thinkingDefault` in config).
4. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
3. Per-agent default (`agents.list[].thinkingDefault` in config).
4. Global default (`agents.defaults.thinkingDefault` in config).
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
## Setting a session default
@@ -50,8 +51,9 @@ title: "Thinking Levels"
- OpenClaw resolves fast mode in this order:
1. Inline/directive-only `/fast on|off`
2. Session override
3. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
4. Fallback: `off`
3. Per-agent default (`agents.list[].fastModeDefault`)
4. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
5. Fallback: `off`
- For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity.
- For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths.
- For direct `anthropic/*` API-key requests, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
@@ -76,6 +78,7 @@ title: "Thinking Levels"
- `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning.
- Alias: `/reason`.
- Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level.
- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then fallback (`off`).
## Related

View File

@@ -390,9 +390,9 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
Telegram sends a voice bubble.
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
## Gateway RPC

View File

@@ -390,9 +390,9 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
Telegram sends a voice bubble.
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
## Gateway RPC

View File

@@ -37,7 +37,6 @@ x-i18n:
- [`status`](/cli/status)
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
- [`report`](/cli/report)
- [`gateway`](/cli/gateway)
- [`logs`](/cli/logs)
- [`system`](/cli/system)
@@ -157,10 +156,6 @@ openclaw [--dev] [--profile <name>] <command>
status
health
sessions
report
bug
feature
security
gateway
call
health

View File

@@ -1,210 +0,0 @@
---
read_when:
- 你想基于本地 OpenClaw 状态生成脱敏后的 GitHub issue 草稿
- 你想用 `gh` 提交公开的 bug 或功能请求
- 你需要私下发送安全报告而不是创建公开 issue
summary: "`openclaw report` 的 CLI 参考bug 报告、功能请求和私有安全报告包)"
title: report
x-i18n:
generated_at: "2026-03-21T03:20:00Z"
model: gpt-5.4
provider: openai
source_path: cli/report.md
workflow: 15
---
# `openclaw report`
`openclaw/openclaw` 准备脱敏后的报告。
`openclaw report` 会把少量用户输入与本地运行时/配置上下文组合起来,生成:
- 公开 bug 报告草稿
- 公开功能请求草稿
- 私有安全报告包
公开的 bug 和功能请求可以选择用 `gh` 提交。安全报告永远不会创建公开 GitHub issue。
## 子命令
- `openclaw report bug`
- `openclaw report feature`
- `openclaw report security`
## 共享标志
- `--title <text>`:显式指定报告标题
- `--summary <text>`:简短摘要(公开报告中使用,也可作为回退标题)
- `--json`:输出结构化的脱敏 payload
- `--markdown`:仅输出渲染后的报告正文
- `--output <file>`:将脱敏后的正文写入文件
- `--submit`:在报告完整时提交公开 bug/feature issue
- `--yes`:跳过提交前的交互确认
- `--non-interactive`:禁用提示;公开提交时需要同时传 `--yes`
如果既没有传 `--json` 也没有传 `--markdown`,默认输出为人类可读的脱敏预览。
`--yes` 只会跳过最后的交互确认,不会跳过草稿生成、诊断收集或 probe 执行。
## Bug 报告
`openclaw report bug` 用于故障行为、回归问题或运行失败。
示例:
```bash
openclaw report bug \
--summary "Gateway 在 mitmproxy 后超时" \
--repro "1. 在代理后启动 gateway\n2. 发送任意 LLM 请求" \
--expected "模型成功响应" \
--actual "请求因超时失败" \
--impact "阻塞所有 LLM 流量"
```
```bash
openclaw report bug \
--summary "Gateway 在 mitmproxy 后超时" \
--repro "1. 在代理后启动 gateway\n2. 发送任意 LLM 请求" \
--expected "模型成功响应" \
--actual "请求因超时失败" \
--impact "阻塞所有 LLM 流量" \
--probe gateway \
--submit
```
Bug 专用标志:
- `--repro <text>`:复现步骤
- `--expected <text>`:期望行为
- `--actual <text>`:实际行为
- `--impact <text>`:对用户或运维的影响
- `--previous-version <text>`:可选的回归版本上下文
- `--evidence <text>`:额外证据
- `--additional-information <text>`:更宽泛的附加细节、线索、时间线或假设
- `--context <text>``--additional-information` 的兼容别名
- `--probe <general|model|channel|gateway|none>`:有限的证据采集模式
要达到可提交的 bug 报告状态,至少需要:
- `summary`
- `repro`
- `expected`
- `actual`
- `impact`
如果可用,还会自动收集:
- OpenClaw 版本
- OS / 运行时摘要
- 已配置的 model / provider 线索
- 在启用 `--probe` 时生成的简短探测摘要
Probe 说明:
- `general`运行时摘要、代理环境上下文、gateway/model/channel 信号,以及可用时的一条近期脱敏错误摘要
- `gateway`gateway 可达性、health 和代理上下文
- `model`provider 认证概览,以及带有代理状态组合输出的有限 live model-path 检查
- `channel`:已配置 channel 摘要,以及近期 channel / runtime 问题线索
## 功能请求
`openclaw report feature` 用于产品改进或新能力需求。
示例:
```bash
openclaw report feature \
--summary "添加 report dry-run 标志" \
--problem "运维希望生成草稿而不直接触发 GitHub" \
--solution "只有显式传 --submit 时才允许真正提交" \
--impact "让脚本化 issue 编写更安全"
```
Feature 专用标志:
- `--problem <text>`:要解决的问题
- `--solution <text>`:提议的解决方案
- `--impact <text>`:预期影响
- `--alternatives <text>`:考虑过的替代方案
- `--evidence <text>`:支持证据或示例
- `--additional-information <text>`:更宽泛的附加细节、线索、时间线或假设
- `--context <text>``--additional-information` 的兼容别名
- `--probe <general|model|channel|gateway|none>`:可选的有限证据采集
要达到可提交的功能请求状态,至少需要:
- `summary`
- `problem`
- `solution`
- `impact`
## 安全报告
`openclaw report security` 用于私下提交漏洞或敏感披露。
示例:
```bash
openclaw report security \
--title "Gateway token 出现在日志中" \
--severity high \
--impact "运维凭证泄露" \
--component "gateway auth logging" \
--reproduction "启用 verbose logging 并运行启动流程" \
--demonstrated-impact "token 出现在终端输出中" \
--environment "macOS 15.4, OpenClaw 2026.3.x" \
--remediation "在日志输出前先 mask 授权值"
```
Security 专用标志:
- `--severity <text>`
- `--impact <text>`
- `--component <text>`
- `--reproduction <text>`
- `--demonstrated-impact <text>`
- `--environment <text>`
- `--remediation <text>`
规则:
- `report security` 永远不会调用 `gh issue create`
- `--submit` 不会触发公开 issue 创建,而是返回被阻止的提交状态
- 终端输出会保持为适合私有报告的内容
- 可使用 `--output``--markdown` 保存私有报告包,后续手动发送
私有提交路径:将完整安全报告发送到 `security@openclaw.ai`
## 脱敏与提交流程
该命令会在渲染输出或提交前自动脱敏常见敏感值:
- token / bearer 值 / API key
- 邮箱地址
- 电话号码
- 私人用户句柄
- 本地用户路径前缀,例如 `/Users/<name>``/home/<name>`
对于公开的 bug 和 feature 报告:
- 只有显式传 `--submit` 才会创建 GitHub issue
- 交互式运行时会在 `gh issue create` 前要求确认
- 非交互式提交需要同时传 `--submit``--yes`
- 如果缺少必填字段,命令会返回结构化的阻止状态,而不是猜测内容
- 生成后的报告正文会附带一条简短来源说明,表明该草稿由 `openclaw report` 生成
## JSON 输出
`--json` 会输出稳定的脱敏 payload字段包括
- `kind`
- `title`
- `body`
- `labels`
- `evidence`
- `redactionsApplied`
- `missingFields`
- `submissionEligible`
- `submission`
该输出适合脚本和更高层的自动化流程。

View File

@@ -5,7 +5,7 @@ import {
type ProviderAuthContext,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/plugin-entry";
import {
CLAUDE_CLI_PROFILE_ID,
applyAuthProfileConfig,

View File

@@ -6,6 +6,17 @@
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.14"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,32 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import {
createBlueBubblesMonitorTestRuntime,
EMPTY_DISPATCH_RESULT,
resetBlueBubblesMonitorTestState,
type DispatchReplyParams,
} from "../../../test/helpers/extensions/bluebubbles-monitor.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
LOOPBACK_REMOTE_ADDRESSES_FOR_TEST,
createWebhookDispatchForTest,
createMockAccount,
createHangingWebhookRequestForTest,
createLoopbackWebhookRequestParamsForTest,
createPasswordQueryRequestParamsForTest,
createProtectedWebhookAccountForTest,
createRemoteWebhookRequestParamsForTest,
createTimestampedNewMessagePayloadForTest,
dispatchWebhookPayloadForTest,
expectWebhookRequestStatusForTest,
expectWebhookStatusForTest,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
trackWebhookRegistrationForTest,
type WebhookRequestParams,
} from "./monitor.webhook.test-helpers.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
@@ -70,13 +85,6 @@ const mockMatchesMentionWithExplicit = vi.fn(
);
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,
);
@@ -99,162 +107,51 @@ 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);
const TEST_WEBHOOK_PASSWORD = "secret-token";
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,
},
},
return createBlueBubblesMonitorTestRuntime({
enqueueSystemEvent: mockEnqueueSystemEvent,
chunkMarkdownText: mockChunkMarkdownText,
chunkByNewline: mockChunkByNewline,
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
chunkTextWithMode: mockChunkTextWithMode,
resolveChunkMode: mockResolveChunkMode,
hasControlCommand: mockHasControlCommand,
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher,
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions,
resolveAgentRoute: mockResolveAgentRoute,
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
saveMediaBuffer: mockSaveMediaBuffer,
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
resolveGroupPolicy: mockResolveGroupPolicy,
resolveRequireMention: mockResolveRequireMention,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
});
}
function createMockAccount(
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
): ResolvedBlueBubblesAccount {
return {
accountId: "default",
enabled: true,
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password", // pragma: allowlist secret
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());
resetBlueBubblesMonitorTestState({
createRuntime: createMockRuntime,
fetchHistoryMock: mockFetchBlueBubblesHistory,
readAllowFromStoreMock: mockReadAllowFromStore,
upsertPairingRequestMock: mockUpsertPairingRequest,
resolveRequireMentionMock: mockResolveRequireMention,
hasControlCommandMock: mockHasControlCommand,
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
buildMentionRegexesMock: mockBuildMentionRegexes,
});
});
afterEach(() => {
@@ -267,75 +164,145 @@ describe("BlueBubbles webhook monitor", () => {
core?: PluginRuntime;
statusSink?: (event: unknown) => void;
}) {
const account = params?.account ?? createMockAccount();
const config = params?.config ?? {};
const core = params?.core ?? createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: params?.statusSink,
});
return { account, config, core };
}
function createNewMessagePayload(dataOverrides: Record<string, unknown> = {}) {
return {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
...dataOverrides,
const registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
statusSink: params?.statusSink,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
};
}
function setRequestRemoteAddress(req: IncomingMessage, remoteAddress: string) {
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
}
async function dispatchWebhook(req: IncomingMessage) {
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
return { handled, res };
}
function createWebhookRequestForTest(params?: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}) {
const req = createMockRequest(
params?.method ?? "POST",
params?.url ?? "/bluebubbles-webhook",
params?.body ?? {},
params?.headers,
);
if (params?.remoteAddress) {
setRequestRemoteAddress(req, params.remoteAddress);
}
return req;
return {
account: registration.account,
config: registration.config,
core: registration.core,
};
}
function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
const req = new EventEmitter() as IncomingMessage;
const destroyMock = vi.fn();
req.method = "POST";
req.url = url;
req.headers = {};
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
setRequestRemoteAddress(req, "127.0.0.1");
return { req, destroyMock };
function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
return setupWebhookTargetAccount(createProtectedWebhookTarget(password).account);
}
function setupPasswordlessWebhookTarget() {
return setupWebhookTargetAccount(createPasswordlessWebhookTarget().account);
}
function setupWebhookTargetAccount(account: ResolvedBlueBubblesAccount) {
setupWebhookTarget({ account });
return account;
}
function createWebhookTarget(
account: ResolvedBlueBubblesAccount,
statusSink: (event: unknown) => void = vi.fn(),
) {
return { account, statusSink };
}
function createProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
return createWebhookTarget(createProtectedWebhookAccountForTest(password));
}
function createPasswordlessWebhookTarget() {
return createWebhookTarget(createMockAccount({ password: undefined }));
}
function createProtectedPasswordQueryRequestParams(password = TEST_WEBHOOK_PASSWORD) {
return createPasswordQueryRequestParamsForTest({ password });
}
async function expectWebhookRequestStatusWithSetup(
setup: () => void,
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
setup();
return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody);
}
async function dispatchWebhookPayloadWithSetup(setup: () => void, payload: unknown) {
setup();
return dispatchWebhookPayloadForTest({ body: payload });
}
async function expectProtectedPasswordQueryRequestStatus(
expectedStatus: number,
password = TEST_WEBHOOK_PASSWORD,
) {
return expectWebhookRequestStatusForTest(
createProtectedPasswordQueryRequestParams(password),
expectedStatus,
);
}
async function expectProtectedWebhookRequestStatus(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
return expectWebhookRequestStatusWithSetup(
() => {
setupProtectedWebhookTarget();
},
params,
expectedStatus,
expectedBody,
);
}
async function expectRegisteredWebhookRequestStatus(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
return expectWebhookRequestStatusWithSetup(
() => {
setupWebhookTarget();
},
params,
expectedStatus,
expectedBody,
);
}
async function dispatchRegisteredWebhookPayload(payload: unknown) {
return dispatchWebhookPayloadWithSetup(() => {
setupWebhookTarget();
}, payload);
}
async function expectLoopbackWebhookRequestStatus(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
expectedStatus: number,
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
) {
return expectWebhookRequestStatusForTest(
createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }),
expectedStatus,
);
}
async function expectProtectedLoopbackWebhookRequestStatus(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
expectedStatus: number,
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
) {
setupProtectedWebhookTarget();
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
}
async function expectPasswordlessLoopbackWebhookRequestStatus(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
expectedStatus: number,
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
) {
setupPasswordlessWebhookTarget();
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
}
function registerWebhookTargets(
@@ -344,70 +311,37 @@ describe("BlueBubbles webhook monitor", () => {
statusSink?: (event: unknown) => void;
}>,
) {
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const unregisterFns = params.map(({ account, statusSink }) =>
registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink,
trackWebhookRegistrationForTest(
setupWebhookTargetsForTest({
createCore: createMockRuntime,
accounts: params,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
unregister = () => {
for (const unregisterFn of unregisterFns) {
unregisterFn();
}
};
}
async function expectWebhookStatus(
req: IncomingMessage,
expectedStatus: number,
expectedBody?: string,
) {
const { handled, res } = await dispatchWebhook(req);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatus);
if (expectedBody !== undefined) {
expect(res.body).toBe(expectedBody);
}
return res;
}
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
setupWebhookTarget();
const req = createWebhookRequestForTest({ method: "GET" });
await expectWebhookStatus(req, 405);
await expectRegisteredWebhookRequestStatus({ method: "GET" }, 405);
});
it("accepts POST requests with valid JSON payload", async () => {
setupWebhookTarget();
const payload = createNewMessagePayload({ date: Date.now() });
const req = createWebhookRequestForTest({ body: payload });
await expectWebhookStatus(req, 200, "ok");
const payload = createTimestampedNewMessagePayloadForTest();
await expectRegisteredWebhookRequestStatus({ body: payload }, 200, "ok");
});
it("rejects requests with invalid JSON", async () => {
setupWebhookTarget();
const req = createWebhookRequestForTest({ body: "invalid json {{" });
await expectWebhookStatus(req, 400);
await expectRegisteredWebhookRequestStatus({ body: "invalid json {{" }, 400);
});
it("accepts URL-encoded payload wrappers", async () => {
setupWebhookTarget();
const payload = createNewMessagePayload({ date: Date.now() });
const payload = createTimestampedNewMessagePayloadForTest();
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createWebhookRequestForTest({ body: encodedBody });
await expectWebhookStatus(req, 200, "ok");
await expectRegisteredWebhookRequestStatus({ body: encodedBody }, 200, "ok");
});
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
@@ -416,11 +350,9 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
// Create a request that never sends data or ends (simulates slow-loris)
const { req, destroyMock } = createHangingWebhookRequest();
const { req, destroyMock } = createHangingWebhookRequestForTest();
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
const { res, handledPromise } = createWebhookDispatchForTest(req);
// Advance past the 30s timeout
await vi.advanceTimersByTimeAsync(31_000);
@@ -435,123 +367,78 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
setupProtectedWebhookTarget();
const { req } = createHangingWebhookRequestForTest(
"/bluebubbles-webhook?password=wrong-token",
);
const onSpy = vi.spyOn(req, "on");
await expectWebhookStatus(req, 401);
await expectWebhookStatusForTest(req, 401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
body: createNewMessagePayload(),
headers: { "x-password": "secret-token" }, // pragma: allowlist secret
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
await expectProtectedWebhookRequestStatus(
createRemoteWebhookRequestParamsForTest({
overrides: {
headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret
},
}),
200,
);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=wrong-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 401);
await expectProtectedWebhookRequestStatus(
createProtectedPasswordQueryRequestParams("wrong-token"),
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 sinkA = vi.fn();
const sinkB = vi.fn();
registerWebhookTargets([
{ account: accountA, statusSink: sinkA },
{ account: accountB, statusSink: sinkB },
]);
const targetA = createProtectedWebhookTarget();
const targetB = createProtectedWebhookTarget();
registerWebhookTargets([targetA, targetB]);
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
await expectProtectedPasswordQueryRequestStatus(401);
expect(targetA.statusSink).not.toHaveBeenCalled();
expect(targetB.statusSink).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 sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
registerWebhookTargets([
{ account: accountStrict, statusSink: sinkStrict },
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
]);
const strictTarget = createProtectedWebhookTarget();
const passwordlessTarget = createPasswordlessWebhookTarget();
registerWebhookTargets([strictTarget, passwordlessTarget]);
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkWithoutPassword).not.toHaveBeenCalled();
await expectProtectedPasswordQueryRequestStatus(200);
expect(strictTarget.statusSink).toHaveBeenCalledTimes(1);
expect(passwordlessTarget.statusSink).not.toHaveBeenCalled();
});
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createWebhookRequestForTest({
body: createNewMessagePayload(),
remoteAddress,
});
await expectWebhookStatus(req, 401);
for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) {
await expectProtectedLoopbackWebhookRequestStatus(remoteAddress, 401);
}
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined });
setupWebhookTarget({ account });
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 = createWebhookRequestForTest({
body: createNewMessagePayload(),
headers,
remoteAddress: "127.0.0.1",
});
await expectWebhookStatus(req, 401);
await expectPasswordlessLoopbackWebhookRequestStatus("127.0.0.1", 401, { headers });
}
});
it("ignores unregistered webhook paths", async () => {
const req = createMockRequest("POST", "/unregistered-path", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled } = await dispatchWebhookPayloadForTest({
url: "/unregistered-path",
});
expect(handled).toBe(false);
});
@@ -560,19 +447,13 @@ describe("BlueBubbles webhook monitor", () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
const payload = createNewMessagePayload({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatId: "123",
date: Date.now(),
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await dispatchRegisteredWebhookPayload(payload);
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
@@ -591,19 +472,13 @@ describe("BlueBubbles webhook monitor", () => {
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
const payload = createNewMessagePayload({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await dispatchRegisteredWebhookPayload(payload);
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(

View File

@@ -0,0 +1,369 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { expect, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { handleBlueBubblesWebhookRequest } from "./monitor.js";
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
export type WebhookRequestParams = {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
};
export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const;
export 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,
},
};
}
export function createProtectedWebhookAccountForTest(password = "test-password") {
return createMockAccount({ password });
}
export function createNewMessagePayloadForTest(dataOverrides: Record<string, unknown> = {}) {
return {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
...dataOverrides,
},
};
}
export function createTimestampedNewMessagePayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createNewMessagePayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMessageReactionPayloadForTest(dataOverrides: Record<string, unknown> = {}) {
return {
type: "message-reaction",
data: {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
associatedMessageGuid: "msg-original-123",
associatedMessageType: 2000,
...dataOverrides,
},
};
}
export function createTimestampedMessageReactionPayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createMessageReactionPayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMockRequest(
method: string,
url: string,
body: unknown,
headers: Record<string, string> = {},
remoteAddress = "127.0.0.1",
): 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 };
// Emit body data after a microtask.
void Promise.resolve().then(() => {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
req.emit("data", Buffer.from(bodyStr));
req.emit("end");
});
return req;
}
export function createMockRequestForTest(params: WebhookRequestParams = {}): IncomingMessage {
return createMockRequest(
params.method ?? "POST",
params.url ?? "/bluebubbles-webhook",
params.body ?? {},
params.headers,
params.remoteAddress,
);
}
export function createRemoteWebhookRequestParamsForTest(
params: {
body?: unknown;
remoteAddress?: string;
overrides?: WebhookRequestParams;
} = {},
): WebhookRequestParams {
return {
body: params.body ?? createNewMessagePayloadForTest(),
remoteAddress: params.remoteAddress ?? "192.168.1.100",
...params.overrides,
};
}
export function createPasswordQueryRequestParamsForTest(
params: {
body?: unknown;
password?: string;
remoteAddress?: string;
overrides?: Omit<WebhookRequestParams, "url">;
} = {},
): WebhookRequestParams {
return createRemoteWebhookRequestParamsForTest({
body: params.body,
remoteAddress: params.remoteAddress,
overrides: {
url: `/bluebubbles-webhook?password=${params.password ?? "test-password"}`,
...params.overrides,
},
});
}
export function createLoopbackWebhookRequestParamsForTest(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
params: {
body?: unknown;
overrides?: Omit<WebhookRequestParams, "remoteAddress">;
} = {},
): WebhookRequestParams {
return {
body: params.body ?? createNewMessagePayloadForTest(),
remoteAddress,
...params.overrides,
};
}
export function createHangingWebhookRequestForTest(
url = "/bluebubbles-webhook?password=test-password",
remoteAddress = "127.0.0.1",
) {
const req = new EventEmitter() as IncomingMessage;
const destroyMock = vi.fn();
req.method = "POST";
req.url = url;
req.headers = {};
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress };
return { req, destroyMock };
}
export 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;
}
export async function flushAsync() {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
export function createWebhookDispatchForTest(req: IncomingMessage) {
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
return { res, handledPromise };
}
export async function dispatchWebhookRequestForTest(
req: IncomingMessage,
options: { flushAsyncAfter?: boolean } = {},
) {
const { res, handledPromise } = createWebhookDispatchForTest(req);
const handled = await handledPromise;
if (options.flushAsyncAfter) {
await flushAsync();
}
return { handled, res };
}
export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams = {}) {
const req = createMockRequestForTest(params);
return dispatchWebhookRequestForTest(req, { flushAsyncAfter: true });
}
export async function expectWebhookStatusForTest(
req: IncomingMessage,
expectedStatus: number,
expectedBody?: string,
) {
const { res, handled } = await dispatchWebhookRequestForTest(req);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatus);
if (expectedBody !== undefined) {
expect(res.body).toBe(expectedBody);
}
return res;
}
export async function expectWebhookRequestStatusForTest(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
return expectWebhookStatusForTest(createMockRequestForTest(params), expectedStatus, expectedBody);
}
export function trackWebhookRegistrationForTest<T extends { unregister: () => void }>(
registration: T,
setUnregister: (unregister: () => void) => void,
) {
setUnregister(registration.unregister);
return registration;
}
export function registerWebhookTargetForTest(params: {
core: PluginRuntime;
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
path?: string;
statusSink?: (event: unknown) => void;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
setBlueBubblesRuntime(params.core);
return registerBlueBubblesWebhookTarget({
account: params.account ?? createMockAccount(),
config: params.config ?? {},
runtime: params.runtime ?? { log: vi.fn(), error: vi.fn() },
core: params.core,
path: params.path ?? "/bluebubbles-webhook",
statusSink: params.statusSink,
});
}
export function registerWebhookTargetsForTest(params: {
core: PluginRuntime;
accounts: Array<{
account: ResolvedBlueBubblesAccount;
statusSink?: (event: unknown) => void;
}>;
config?: OpenClawConfig;
path?: string;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
return params.accounts.map(({ account, statusSink }) =>
registerWebhookTargetForTest({
core: params.core,
account,
config: params.config,
path: params.path,
runtime: params.runtime,
statusSink,
}),
);
}
export function setupWebhookTargetForTest(params: {
createCore: () => PluginRuntime;
core?: PluginRuntime;
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
path?: string;
statusSink?: (event: unknown) => void;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
const account = params.account ?? createMockAccount();
const config = params.config ?? {};
const core = params.core ?? params.createCore();
const unregister = registerWebhookTargetForTest({
core,
account,
config,
path: params.path,
statusSink: params.statusSink,
runtime: params.runtime,
});
return { account, config, core, unregister };
}
export function setupWebhookTargetsForTest(params: {
createCore: () => PluginRuntime;
core?: PluginRuntime;
accounts: Array<{
account: ResolvedBlueBubblesAccount;
statusSink?: (event: unknown) => void;
}>;
config?: OpenClawConfig;
path?: string;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
const core = params.core ?? params.createCore();
const unregisterFns = registerWebhookTargetsForTest({
core,
accounts: params.accounts,
config: params.config,
path: params.path,
runtime: params.runtime,
});
const unregister = () => {
for (const unregisterFn of unregisterFns) {
unregisterFn();
}
};
return { core, unregister };
}

View File

@@ -1 +1 @@
export * from "../../../src/plugin-sdk/bluebubbles.js";
export * from "openclaw/plugin-sdk/bluebubbles";

View File

@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
createSetupWizardAdapter,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
@@ -27,17 +31,26 @@ async function createBlueBubblesConfigureAdapter() {
}).config.allowFrom ?? [],
},
setup: blueBubblesSetupAdapter,
} as Parameters<typeof buildChannelSetupWizardAdapterFromSetupWizard>[0]["plugin"];
return buildChannelSetupWizardAdapterFromSetupWizard({
} as Parameters<typeof createSetupWizardAdapter>[0]["plugin"];
return createSetupWizardAdapter({
plugin,
wizard: blueBubblesSetupWizard,
});
}
async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) {
const adapter = await createBlueBubblesConfigureAdapter();
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
return await runSetupWizardConfigure({
configure: adapter.configure,
cfg: params.cfg as ConfigureContext["cfg"],
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
prompter: params.prompter,
});
}
describe("bluebubbles setup surface", () => {
it("preserves existing password SecretRef and keeps default webhook path", async () => {
const adapter = await createBlueBubblesConfigureAdapter();
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
const confirm = vi
.fn()
@@ -45,10 +58,8 @@ describe("bluebubbles setup surface", () => {
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
const text = vi.fn();
const note = vi.fn();
const prompter = { confirm, text, note } as unknown as WizardPrompter;
const context = {
const result = await runBlueBubblesConfigure({
cfg: {
channels: {
bluebubbles: {
@@ -58,14 +69,8 @@ describe("bluebubbles setup surface", () => {
},
},
},
prompter,
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
forceAllowFrom: false,
accountOverrides: {},
shouldPromptAccountIds: false,
} satisfies ConfigureContext;
const result = await adapter.configure(context);
prompter: createTestWizardPrompter({ confirm, text }),
});
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH);
@@ -73,18 +78,14 @@ describe("bluebubbles setup surface", () => {
});
it("applies a custom webhook path when requested", async () => {
const adapter = await createBlueBubblesConfigureAdapter();
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
const confirm = vi
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles");
const note = vi.fn();
const prompter = { confirm, text, note } as unknown as WizardPrompter;
const context = {
const result = await runBlueBubblesConfigure({
cfg: {
channels: {
bluebubbles: {
@@ -94,14 +95,8 @@ describe("bluebubbles setup surface", () => {
},
},
},
prompter,
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
forceAllowFrom: false,
accountOverrides: {},
shouldPromptAccountIds: false,
} satisfies ConfigureContext;
const result = await adapter.configure(context);
prompter: createTestWizardPrompter({ confirm, text }),
});
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles");
expect(text).toHaveBeenCalledWith(
@@ -113,23 +108,13 @@ describe("bluebubbles setup surface", () => {
});
it("validates server URLs before accepting input", async () => {
const adapter = await createBlueBubblesConfigureAdapter();
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
const confirm = vi.fn().mockResolvedValueOnce(false);
const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret");
const note = vi.fn();
const prompter = { confirm, text, note } as unknown as WizardPrompter;
const context = {
await runBlueBubblesConfigure({
cfg: { channels: { bluebubbles: {} } },
prompter,
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
forceAllowFrom: false,
accountOverrides: {},
shouldPromptAccountIds: false,
} satisfies ConfigureContext;
await adapter.configure(context);
prompter: createTestWizardPrompter({ confirm, text }),
});
const serverUrlPrompt = text.mock.calls[0]?.[0] as {
validate?: (value: string) => string | undefined;

View File

@@ -5,7 +5,7 @@ import {
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "../../imessage/api.js";
} from "openclaw/plugin-sdk/imessage-core";
export type BlueBubblesService = "imessage" | "sms" | "auto";

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
export default definePluginEntry({

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js";

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
buildApiKeyCredential,

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createDiagnosticsOtelService } from "./src/service.js";
export default definePluginEntry({

View File

@@ -10,6 +10,17 @@
"https-proxy-agent": "^8.0.0",
"opusscript": "^0.1.1"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.14"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,10 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
} from "../../../src/channels/plugins/types.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -49,31 +45,28 @@ function createCfg(): OpenClawConfig {
} as OpenClawConfig;
}
function createStartAccountCtx(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedDiscordAccount> {
const account = discordPlugin.config.resolveAccount(
params.cfg,
params.accountId,
) as ResolvedDiscordAccount;
const snapshot: ChannelAccountSnapshot = {
accountId: params.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.accountId,
account,
cfg: params.cfg,
runtime: params.runtime,
abortSignal: new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
};
function resolveAccount(cfg: OpenClawConfig): ResolvedDiscordAccount {
return discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount;
}
function startDiscordAccount(cfg: OpenClawConfig) {
return discordPlugin.gateway!.startAccount!(
createStartAccountContext({
account: resolveAccount(cfg),
cfg,
}),
);
}
function installDiscordRuntime(discord: Record<string, unknown>) {
setDiscordRuntime({
channel: {
discord,
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
}
afterEach(() => {
@@ -85,13 +78,9 @@ afterEach(() => {
describe("discordPlugin outbound", () => {
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
setDiscordRuntime({
channel: {
discord: {
sendMessageDiscord,
},
},
} as unknown as PluginRuntime);
installDiscordRuntime({
sendMessageDiscord,
});
const result = await discordPlugin.outbound!.sendMedia!({
cfg: {} as OpenClawConfig,
@@ -117,16 +106,9 @@ describe("discordPlugin outbound", () => {
const runtimeProbeDiscord = vi.fn(async () => {
throw new Error("runtime Discord probe should not be used");
});
setDiscordRuntime({
channel: {
discord: {
probeDiscord: runtimeProbeDiscord,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
installDiscordRuntime({
probeDiscord: runtimeProbeDiscord,
});
probeDiscordMock.mockResolvedValue({
ok: true,
bot: { username: "Bob" },
@@ -141,7 +123,7 @@ describe("discordPlugin outbound", () => {
});
const cfg = createCfg();
const account = discordPlugin.config.resolveAccount(cfg, "default");
const account = resolveAccount(cfg);
await discordPlugin.status!.probeAccount!({
account,
@@ -162,17 +144,10 @@ describe("discordPlugin outbound", () => {
const runtimeMonitorDiscordProvider = vi.fn(async () => {
throw new Error("runtime Discord monitor should not be used");
});
setDiscordRuntime({
channel: {
discord: {
probeDiscord: runtimeProbeDiscord,
monitorDiscordProvider: runtimeMonitorDiscordProvider,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
installDiscordRuntime({
probeDiscord: runtimeProbeDiscord,
monitorDiscordProvider: runtimeMonitorDiscordProvider,
});
probeDiscordMock.mockResolvedValue({
ok: true,
bot: { username: "Bob" },
@@ -188,13 +163,7 @@ describe("discordPlugin outbound", () => {
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
await discordPlugin.gateway!.startAccount!(
createStartAccountCtx({
cfg,
accountId: "default",
runtime: createRuntimeEnv(),
}),
);
await startDiscordAccount(cfg);
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
includeApplication: true,
@@ -210,6 +179,37 @@ describe("discordPlugin outbound", () => {
});
});
describe("discordPlugin security", () => {
it("normalizes dm allowlist entries with trimmed prefixes and mentions", () => {
const resolveDmPolicy = discordPlugin.security?.resolveDmPolicy;
if (!resolveDmPolicy) {
throw new Error("resolveDmPolicy unavailable");
}
const cfg = {
channels: {
discord: {
token: "discord-token",
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] },
},
},
} as OpenClawConfig;
const result = resolveDmPolicy({
cfg,
account: discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount,
});
if (!result) {
throw new Error("discord resolveDmPolicy returned null");
}
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual([" discord:<@!123456789> "]);
expect(result.normalizeEntry?.(" discord:<@!123456789> ")).toBe("123456789");
expect(result.normalizeEntry?.(" user:987654321 ")).toBe("987654321");
});
});
describe("discordPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {

View File

@@ -20,7 +20,7 @@ import {
import {
createRuntimeOutboundDelegates,
resolveOutboundSendDep,
} from "openclaw/plugin-sdk/infra-runtime";
} from "openclaw/plugin-sdk/outbound-runtime";
import {
buildOutboundBaseSessionKey,
normalizeMessageChannel,
@@ -86,7 +86,12 @@ const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAcc
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
normalizeEntry: (raw) =>
raw
.trim()
.replace(/^(discord|user):/i, "")
.trim()
.replace(/^<@!?(\d+)>$/, "$1"),
});
function formatDiscordIntents(intents?: {

View File

@@ -2,7 +2,7 @@ import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "./account-inspect.js";
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({

View File

@@ -3,6 +3,7 @@ import { ChannelType, MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchMock,
loadConfigMock,
readAllowFromStoreMock,
sendMock,
updateLastRouteMock,
@@ -24,6 +25,7 @@ beforeEach(() => {
});
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
loadConfigMock.mockClear().mockReturnValue(BASE_CFG);
});
const BASE_CFG: Config = {
@@ -33,6 +35,9 @@ const BASE_CFG: Config = {
workspace: "/tmp/openclaw",
},
},
messages: {
inbound: { debounceMs: 0 },
},
session: { store: "/tmp/openclaw-sessions.json" },
};
@@ -80,6 +85,7 @@ function createHandlerBaseConfig(
}
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) {
loadConfigMock.mockReturnValue(opts.cfg);
return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError));
}
@@ -92,9 +98,10 @@ function createDmClient() {
} as unknown as Client;
}
async function createCategoryGuildHandler() {
async function createCategoryGuildHandler(runtimeError?: (err: unknown) => void) {
loadConfigMock.mockReturnValue(CATEGORY_GUILD_CFG);
return createDiscordMessageHandler({
...createHandlerBaseConfig(CATEGORY_GUILD_CFG),
...createHandlerBaseConfig(CATEGORY_GUILD_CFG, runtimeError),
guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } },
},

View File

@@ -7,6 +7,7 @@ export const updateLastRouteMock: MockFn = vi.fn();
export const dispatchMock: MockFn = vi.fn();
export const readAllowFromStoreMock: MockFn = vi.fn();
export const upsertPairingRequestMock: MockFn = vi.fn();
export const loadConfigMock: MockFn = vi.fn();
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
@@ -52,6 +53,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),

View File

@@ -156,6 +156,22 @@ describe("createDiscordMessageHandler queue behavior", () => {
});
});
it("drops duplicate inbound message deliveries before they reach preflight", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
const handler = createHandlerWithDefaultPreflight();
const duplicate = createMessageData("m-dup");
await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined();
await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
expect(preflightDiscordMessageMock).toHaveBeenCalledTimes(1);
});
it("applies explicit inbound worker timeout to queued runs so stalled runs do not block the queue", async () => {
vi.useFakeTimers();
try {

View File

@@ -4,6 +4,7 @@ import {
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { buildDiscordInboundJob } from "./inbound-job.js";
import { createDiscordInboundWorker } from "./inbound-worker.js";
@@ -30,6 +31,27 @@ export type DiscordMessageHandlerWithLifecycle = DiscordMessageHandler & {
deactivate: () => void;
};
const RECENT_DISCORD_MESSAGE_TTL_MS = 5 * 60_000;
const RECENT_DISCORD_MESSAGE_MAX = 5000;
function buildDiscordInboundDedupeKey(params: {
accountId: string;
data: DiscordMessageEvent;
}): string | null {
const messageId = params.data.message?.id?.trim();
if (!messageId) {
return null;
}
const channelId = resolveDiscordMessageChannelId({
message: params.data.message,
eventChannelId: params.data.channel_id,
});
if (!channelId) {
return null;
}
return `${params.accountId}:${channelId}:${messageId}`;
}
export function createDiscordMessageHandler(
params: DiscordMessageHandlerParams,
): DiscordMessageHandlerWithLifecycle {
@@ -48,6 +70,10 @@ export function createDiscordMessageHandler(
abortSignal: params.abortSignal,
runTimeoutMs: params.workerRunTimeoutMs,
});
const recentInboundMessages = createDedupeCache({
ttlMs: RECENT_DISCORD_MESSAGE_TTL_MS,
maxSize: RECENT_DISCORD_MESSAGE_MAX,
});
const { debouncer } = createChannelInboundDebouncer<{
data: DiscordMessageEvent;
@@ -173,6 +199,13 @@ export function createDiscordMessageHandler(
if (params.botUserId && msgAuthorId === params.botUserId) {
return;
}
const dedupeKey = buildDiscordInboundDedupeKey({
accountId: params.accountId,
data,
});
if (dedupeKey && recentInboundMessages.check(dedupeKey)) {
return;
}
await debouncer.enqueue({ data, client, abortSignal: options?.abortSignal });
} catch (err) {

View File

@@ -12,6 +12,7 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -50,7 +51,6 @@ import {
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
@@ -90,14 +90,6 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
@@ -147,6 +139,12 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
describe("agent components", () => {
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
const dmSessionKey = buildAgentSessionKey({
agentId: "main",
channel: "discord",
accountId: "default",
peer: { kind: "direct", id: "123456789" },
});
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
@@ -187,7 +185,7 @@ describe("agent components", () => {
beforeEach(() => {
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
enqueueSystemEventMock.mockClear();
resetSystemEventsForTest();
});
it("sends pairing reply when DM sender is not allowlisted", async () => {
@@ -207,7 +205,7 @@ describe("agent components", () => {
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code).toBeDefined();
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
@@ -226,7 +224,7 @@ describe("agent components", () => {
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -243,7 +241,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
@@ -261,7 +261,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -281,7 +283,7 @@ describe("agent components", () => {
content: "DM interactions are disabled.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -299,7 +301,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(peekSystemEvents(dmSessionKey)).toEqual([
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -316,10 +320,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello_cid"),
expect.any(Object),
);
expect(peekSystemEvents(dmSessionKey)).toEqual([
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -336,10 +339,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello%2G"),
expect.any(Object),
);
expect(peekSystemEvents(dmSessionKey)).toEqual([
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
});
@@ -498,7 +500,7 @@ describe("discord component interactions", () => {
lastDispatchCtx = undefined;
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
enqueueSystemEventMock.mockClear();
resetSystemEventsForTest();
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { createNonExitingTypedRuntimeEnv } from "../../../../test/helpers/extensions/runtime-env.js";
const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({
resolveDiscordChannelAllowlistMock: vi.fn(
@@ -35,7 +36,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
describe("resolveDiscordAllowlistConfig", () => {
it("canonicalizes resolved user names to ids in runtime config", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv;
const runtime = createNonExitingTypedRuntimeEnv<RuntimeEnv>();
const result = await resolveDiscordAllowlistConfig({
token: "token",
allowFrom: ["Alice", "111", "*"],
@@ -69,7 +70,7 @@ describe("resolveDiscordAllowlistConfig", () => {
channelName: "missing-room",
},
]);
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv;
const runtime = createNonExitingTypedRuntimeEnv<RuntimeEnv>();
await resolveDiscordAllowlistConfig({
token: "token",

View File

@@ -4,7 +4,10 @@ import {
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import {
resolveOutboundSendDep,
type OutboundIdentity,
} from "openclaw/plugin-sdk/outbound-runtime";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequenceOrFallback,

View File

@@ -4,7 +4,7 @@ export {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "../../../src/plugin-sdk/discord.js";
} from "openclaw/plugin-sdk/discord";
export {
buildChannelConfigSchema,
getChatChannelMeta,
@@ -19,15 +19,15 @@ export {
type DiscordActionConfig,
type DiscordConfig,
type OpenClawConfig,
} from "../../../src/plugin-sdk/discord-core.js";
export { DiscordConfigSchema } from "../../../src/plugin-sdk/discord-core.js";
} from "openclaw/plugin-sdk/discord-core";
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export {
assertMediaNotDataUrl,
parseAvailableTags,
readReactionParams,
withNormalizedTimestamp,
} from "../../../src/plugin-sdk/discord-core.js";
} from "openclaw/plugin-sdk/discord-core";
export {
createHybridChannelConfigAdapter,
createScopedChannelConfigAdapter,

View File

@@ -1,12 +1,29 @@
import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
type DiscordSessionKeyContext = {
ChatType?: string;
From?: string;
SenderId?: string;
};
function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined {
const normalized = (raw ?? "").trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "dm") {
return "direct";
}
if (normalized === "group" || normalized === "channel" || normalized === "direct") {
return normalized;
}
return undefined;
}
export function normalizeExplicitDiscordSessionKey(
sessionKey: string,
ctx: Pick<MsgContext, "ChatType" | "From" | "SenderId">,
ctx: DiscordSessionKeyContext,
): string {
let normalized = sessionKey.trim().toLowerCase();
if (normalizeChatType(ctx.ChatType) !== "direct") {
if (normalizeDiscordChatType(ctx.ChatType) !== "direct") {
return normalized;
}

View File

@@ -0,0 +1,4 @@
export {
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
} from "./src/monitor/timeouts.js";

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildElevenLabsSpeechProvider } from "openclaw/plugin-sdk/speech";
export default definePluginEntry({

View File

@@ -1,5 +1,5 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";

View File

@@ -9,6 +9,17 @@
"https-proxy-agent": "^8.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.14"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/feishu.js";
export * from "openclaw/plugin-sdk/feishu";

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import type { FeishuMessageEvent } from "./bot.js";
import {
@@ -99,16 +100,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
}),
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv;
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
const runtime = createRuntimeEnv();
await handleFeishuMessage({

View File

@@ -17,8 +17,8 @@ import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js";
import {
buildChannelConfigSchema,

View File

@@ -48,6 +48,26 @@ describe("feishu directory (config-backed)", () => {
]);
});
it("normalizes spaced provider-prefixed peer entries", async () => {
resolveFeishuAccountMock.mockReturnValueOnce({
configured: false,
config: {
allowFrom: [" feishu:user:ou_alice "],
dms: {
" lark:dm:ou_carla ": {},
},
groups: {},
groupAllowFrom: [],
},
});
const peers = await listFeishuDirectoryPeers({ cfg });
expect(peers).toEqual([
{ kind: "user", id: "ou_alice" },
{ kind: "user", id: "ou_carla" },
]);
});
it("merges groups map + groupAllowFrom into group entries", async () => {
const groups = await listFeishuDirectoryGroups({ cfg });
expect(groups).toEqual([

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
@@ -139,14 +140,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
} as unknown as ResolvedFeishuAccount;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv;
}
function createTopicEvent(messageId: string) {
return {
sender: {

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
@@ -133,14 +134,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
} as unknown as ResolvedFeishuAccount;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv;
}
function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
return {
event_key: params.eventKey,

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createNonExitingRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
@@ -159,14 +160,6 @@ function createLifecycleAccount(accountId: "account-A" | "account-B"): ResolvedF
} as unknown as ResolvedFeishuAccount;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv;
}
function createBroadcastEvent(messageId: string) {
return {
sender: {
@@ -190,7 +183,7 @@ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
});
createEventDispatcherMock.mockReturnValueOnce({ register });
const runtime = createRuntimeEnv();
const runtime = createNonExitingRuntimeEnv();
runtimesByAccount.set(accountId, runtime);
await monitorSingleAccount({

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
@@ -135,14 +136,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
} as unknown as ResolvedFeishuAccount;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv;
}
function createCardActionEvent(params: {
token: string;
action: string;

View File

@@ -5,6 +5,10 @@ import {
resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import {
createNonExitingTypedRuntimeEnv,
createRuntimeEnv,
} from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
import * as dedup from "./dedup.js";
@@ -172,11 +176,7 @@ async function setupDebounceMonitor(params?: {
await monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
botOpenIdSource: {
kind: "prefetched",
botOpenId: params?.botOpenId ?? "ou_bot",
@@ -452,11 +452,7 @@ describe("monitorSingleAccount lifecycle", () => {
await monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
@@ -493,11 +489,7 @@ describe("monitorSingleAccount lifecycle", () => {
monitorSingleAccount({
cfg: buildDebounceConfig(),
account: buildDebounceAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
@@ -140,14 +141,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
} as unknown as ResolvedFeishuAccount;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv;
}
function createTextEvent(messageId: string) {
return {
sender: {

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createNonExitingRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ClawdbotConfig } from "../runtime-api.js";
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
@@ -134,7 +135,7 @@ describe("Feishu monitor startup preflight", () => {
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const runtime = createNonExitingRuntimeEnv();
const monitorPromise = monitorFeishuProvider({
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
runtime,

View File

@@ -1,16 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createPluginSetupWizardStatus } from "../../../test/helpers/extensions/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { feishuPlugin } from "./channel.js";
const feishuConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
describe("feishu setup wizard status", () => {
it("treats SecretRef appSecret as configured when appId is present", async () => {
const status = await feishuConfigureAdapter.getStatus({
const status = await feishuGetStatus({
cfg: {
channels: {
feishu: {

View File

@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import {
createPluginSetupWizardConfigure,
createPluginSetupWizardStatus,
createTestWizardPrompter,
runSetupWizardConfigure,
} from "../../../test/helpers/extensions/setup-wizard.js";
vi.mock("./probe.js", () => ({
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
@@ -7,13 +13,6 @@ vi.mock("./probe.js", () => ({
import { feishuPlugin } from "./channel.js";
const baseConfigureContext = {
runtime: {} as never,
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
};
const baseStatusContext = {
accountOverrides: {},
};
@@ -43,7 +42,7 @@ async function withEnvVars(values: Record<string, string | undefined>, run: () =
}
async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
return await feishuConfigureAdapter.getStatus({
return await feishuGetStatus({
cfg: {
channels: {
feishu: {
@@ -56,10 +55,9 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
});
}
const feishuConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});
const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin);
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
type FeishuConfigureRuntime = Parameters<typeof feishuConfigure>[0]["runtime"];
describe("feishu setup wizard", () => {
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
@@ -68,18 +66,17 @@ describe("feishu setup wizard", () => {
.mockResolvedValueOnce("cli_from_prompt")
.mockResolvedValueOnce("secret_from_prompt")
.mockResolvedValueOnce("oc_group_1");
const prompter = {
note: vi.fn(async () => undefined),
const prompter = createTestWizardPrompter({
text,
confirm: vi.fn(async () => true),
select: vi.fn(
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
),
} as never;
) as never,
});
await expect(
feishuConfigureAdapter.configure({
runSetupWizardConfigure({
configure: feishuConfigure,
cfg: {
channels: {
feishu: {
@@ -89,7 +86,7 @@ describe("feishu setup wizard", () => {
},
} as never,
prompter,
...baseConfigureContext,
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
}),
).resolves.toBeTruthy();
});
@@ -97,7 +94,7 @@ describe("feishu setup wizard", () => {
describe("feishu setup wizard status", () => {
it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
const status = await feishuConfigureAdapter.getStatus({
const status = await feishuGetStatus({
cfg: {
channels: {
feishu: {

View File

@@ -1,4 +1,4 @@
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core";
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js";
import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js";
import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js";

View File

@@ -1,5 +1,5 @@
import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime";
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core";
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login";
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";

View File

@@ -1,3 +1,4 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
import { googlechatPlugin } from "./src/channel.js";
import { setGoogleChatRuntime } from "./src/runtime.js";
@@ -9,6 +10,6 @@ export default defineChannelPluginEntry({
id: "googlechat",
name: "Google Chat",
description: "OpenClaw Google Chat channel plugin",
plugin: googlechatPlugin,
plugin: googlechatPlugin as ChannelPlugin,
setRuntime: setGoogleChatRuntime,
});

View File

@@ -7,8 +7,11 @@
"dependencies": {
"google-auth-library": "^10.6.2"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.11"
"openclaw": ">=2026.3.14"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Google Chat extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/googlechat.js";
export * from "openclaw/plugin-sdk/googlechat";

View File

@@ -55,4 +55,32 @@ describe("googlechat directory", () => {
]),
);
});
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(googlechatPlugin.directory);
await expect(
directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "users/alice" },
{ kind: "user", id: "users/bob@example.com" },
]),
);
});
});

View File

@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
abortStartedAccount,
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
import type { ChannelAccountSnapshot } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
@@ -21,6 +21,21 @@ vi.mock("./monitor.js", async () => {
import { googlechatPlugin } from "./channel.js";
function buildAccount(): ResolvedGoogleChatAccount {
return {
accountId: "default",
enabled: true,
credentialSource: "inline",
credentials: {},
config: {
webhookPath: "/googlechat",
webhookUrl: "https://example.com/googlechat",
audienceType: "app-url",
audience: "https://example.com/googlechat",
},
};
}
describe("googlechatPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -30,28 +45,12 @@ describe("googlechatPlugin gateway.startAccount", () => {
const unregister = vi.fn();
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
const account: ResolvedGoogleChatAccount = {
accountId: "default",
enabled: true,
credentialSource: "inline",
credentials: {},
config: {
webhookPath: "/googlechat",
webhookUrl: "https://example.com/googlechat",
audienceType: "app-url",
audience: "https://example.com/googlechat",
},
};
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: googlechatPlugin.gateway!.startAccount!,
account,
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: () =>
vi.waitFor(() => {
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
}),
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
isSettled,
abort,
task,
@@ -62,7 +61,7 @@ describe("googlechatPlugin gateway.startAccount", () => {
expect(unregister).toHaveBeenCalledOnce();
},
});
expect(patches.some((entry) => entry.running === true)).toBe(true);
expect(patches.some((entry) => entry.running === false)).toBe(true);
expectLifecyclePatch(patches, { running: true });
expectLifecyclePatch(patches, { running: false });
});
});

View File

@@ -1,17 +1,12 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing";
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import {
composeWarningCollectors,
createAllowlistProviderGroupPolicyWarningCollector,
createConditionalWarningCollector,
createAllowlistProviderOpenWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
listResolvedDirectoryGroupEntriesFromMapKeys,
@@ -30,7 +25,6 @@ import {
resolveChannelMediaMaxBytes,
runPassiveAccountLifecycle,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ChannelStatusIssue,
type OpenClawConfig,
} from "../runtime-api.js";
@@ -92,14 +86,6 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleC
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
});
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
channelKey: "googlechat",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => formatAllowFromEntry(raw),
});
const googlechatActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null,
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
@@ -135,138 +121,258 @@ const collectGoogleChatSecurityWarnings = composeWarningCollectors<{
),
);
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
id: "googlechat",
meta: { ...meta },
setup: googlechatSetupAdapter,
setupWizard: googlechatSetupWizard,
pairing: createTextPairingAdapter({
idLabel: "googlechatUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notify: async ({ cfg, id, message }) => {
const account = resolveGoogleChatAccount({ cfg: cfg });
if (account.credentialSource === "none") {
return;
}
const user = normalizeGoogleChatTarget(id) ?? id;
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
const space = await resolveGoogleChatOutboundSpace({ account, target });
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
await sendGoogleChatMessage({
account,
space,
text: message,
});
export const googlechatPlugin = createChatChannelPlugin({
base: {
id: "googlechat",
meta: { ...meta },
setup: googlechatSetupAdapter,
setupWizard: googlechatSetupWizard,
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: false,
blockStreaming: true,
},
}),
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
...googleChatConfigAdapter,
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
...googleChatConfigAdapter,
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
}),
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},
messaging: {
normalizeTarget: normalizeGoogleChatTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {
const value = normalized ?? raw.trim();
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
},
hint: "<spaces/{space}|users/{user}>",
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
}),
listGroups: async (params) =>
listResolvedDirectoryGroupEntriesFromMapKeys({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveGroups: (account) => account.config.groups,
}),
}),
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
const normalized = normalizeGoogleChatTarget(input);
if (!normalized) {
return { input, resolved: false, note: "empty target" };
}
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
return {
input,
resolved: false,
note: "use spaces/{space} or users/{user}",
};
});
return resolved;
},
},
actions: googlechatActions,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
accounts.flatMap((entry) => {
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
const enabled = entry.enabled !== false;
const configured = entry.configured === true;
if (!enabled || !configured) {
return [];
}
const issues: ChannelStatusIssue[] = [];
if (!entry.audience) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audience is missing (set channels.googlechat.audience).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
if (!entry.audienceType) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audienceType is missing (app-url or project-number).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
return issues;
}),
buildChannelSummary: ({ snapshot }) =>
buildPassiveProbedChannelStatusSummary(snapshot, {
credentialSource: snapshot.credentialSource ?? "none",
audienceType: snapshot.audienceType ?? null,
audience: snapshot.audience ?? null,
webhookPath: snapshot.webhookPath ?? null,
webhookUrl: snapshot.webhookUrl ?? null,
}),
probeAccount: async ({ account }) =>
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
runtime,
probe,
});
return {
...base,
credentialSource: account.credentialSource,
audienceType: account.config.audienceType,
audience: account.config.audience,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
dmPolicy: account.config.dm?.policy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const statusSink = createAccountStatusSink({
accountId: account.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
await loadGoogleChatChannelRuntime();
statusSink({
running: true,
lastStartAt: Date.now(),
webhookPath: resolveGoogleChatWebhookPath({ account }),
audienceType: account.config.audienceType,
audience: account.config.audience,
});
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
start: async () =>
await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink,
}),
stop: async (unregister) => {
unregister?.();
},
onStop: async () => {
statusSink({
running: false,
lastStopAt: Date.now(),
});
},
});
},
},
},
pairing: {
text: {
idLabel: "googlechatUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notify: async ({ cfg, id, message }) => {
const account = resolveGoogleChatAccount({ cfg: cfg });
if (account.credentialSource === "none") {
return;
}
const user = normalizeGoogleChatTarget(id) ?? id;
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
const space = await resolveGoogleChatOutboundSpace({ account, target });
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
await sendGoogleChatMessage({
account,
space,
text: message,
});
},
},
},
security: {
resolveDmPolicy: resolveGoogleChatDmPolicy,
dm: {
channelKey: "googlechat",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => formatAllowFromEntry(raw),
},
collectWarnings: collectGoogleChatSecurityWarnings,
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},
threading: {
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"),
topLevelReplyToMode: "googlechat",
},
messaging: {
normalizeTarget: normalizeGoogleChatTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {
const value = normalized ?? raw.trim();
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
},
hint: "<spaces/{space}|users/{user}>",
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
}),
listGroups: async (params) =>
listResolvedDirectoryGroupEntriesFromMapKeys({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveGroups: (account) => account.config.groups,
}),
}),
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
const normalized = normalizeGoogleChatTarget(input);
if (!normalized) {
return { input, resolved: false, note: "empty target" };
}
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
return {
input,
resolved: false,
note: "use spaces/{space} or users/{user}",
};
});
return resolved;
},
},
actions: googlechatActions,
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim() ?? "";
base: {
deliveryMode: "direct",
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim() ?? "";
if (trimmed) {
const normalized = normalizeGoogleChatTarget(trimmed);
if (!normalized) {
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
if (trimmed) {
const normalized = normalizeGoogleChatTarget(trimmed);
if (!normalized) {
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
}
return { ok: true, to: normalized };
}
return { ok: true, to: normalized };
}
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
},
},
...createAttachedChannelResultAdapter({
attachedResults: {
channel: "googlechat",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveGoogleChatAccount({
@@ -356,114 +462,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
chatId: space,
};
},
}),
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
accounts.flatMap((entry) => {
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
const enabled = entry.enabled !== false;
const configured = entry.configured === true;
if (!enabled || !configured) {
return [];
}
const issues: ChannelStatusIssue[] = [];
if (!entry.audience) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audience is missing (set channels.googlechat.audience).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
if (!entry.audienceType) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audienceType is missing (app-url or project-number).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
return issues;
}),
buildChannelSummary: ({ snapshot }) =>
buildPassiveProbedChannelStatusSummary(snapshot, {
credentialSource: snapshot.credentialSource ?? "none",
audienceType: snapshot.audienceType ?? null,
audience: snapshot.audience ?? null,
webhookPath: snapshot.webhookPath ?? null,
webhookUrl: snapshot.webhookUrl ?? null,
}),
probeAccount: async ({ account }) =>
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
runtime,
probe,
});
return {
...base,
credentialSource: account.credentialSource,
audienceType: account.config.audienceType,
audience: account.config.audience,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
dmPolicy: account.config.dm?.policy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const statusSink = createAccountStatusSink({
accountId: account.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
await loadGoogleChatChannelRuntime();
statusSink({
running: true,
lastStartAt: Date.now(),
webhookPath: resolveGoogleChatWebhookPath({ account }),
audienceType: account.config.audienceType,
audience: account.config.audience,
});
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
start: async () =>
await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink,
}),
stop: async (unregister) => {
unregister?.();
},
onStop: async () => {
statusSink({
running: false,
lastStopAt: Date.now(),
});
},
});
},
},
};
});

View File

@@ -1,17 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { googlechatPlugin } from "./channel.js";
const googlechatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
plugin: googlechatPlugin,
wizard: googlechatPlugin.setupWizard!,
});
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin);
describe("googlechat setup wizard", () => {
it("configures service-account auth and webhook audience", async () => {
@@ -27,16 +24,11 @@ describe("googlechat setup wizard", () => {
}) as WizardPrompter["text"],
});
const runtime = createRuntimeEnv();
const result = await googlechatConfigureAdapter.configure({
const result = await runSetupWizardConfigure({
configure: googlechatConfigure,
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildHuggingfaceProvider } from "./provider-catalog.js";

View File

@@ -13,7 +13,7 @@ export {
IMessageConfigSchema,
type ChannelPlugin,
type IMessageAccountConfig,
} from "../../src/plugin-sdk/imessage.js";
} from "openclaw/plugin-sdk/imessage";
export {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,

View File

@@ -1,6 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { imessagePlugin } from "./channel.js";
function requireIMessageSendText() {
const sendText = imessagePlugin.outbound?.sendText;
if (!sendText) {
throw new Error("imessage outbound.sendText unavailable");
}
return sendText;
}
function requireIMessageSendMedia() {
const sendMedia = imessagePlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("imessage outbound.sendMedia unavailable");
}
return sendMedia;
}
describe("imessagePlugin outbound", () => {
const cfg = {
channels: {
@@ -12,10 +28,9 @@ describe("imessagePlugin outbound", () => {
it("forwards replyToId on direct sendText adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = imessagePlugin.outbound?.sendText;
expect(sendText).toBeDefined();
const sendText = requireIMessageSendText();
const result = await sendText!({
const result = await sendText({
cfg,
to: "chat_id:12",
text: "hello",
@@ -38,10 +53,9 @@ describe("imessagePlugin outbound", () => {
it("forwards replyToId on direct sendMedia adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media" });
const sendMedia = imessagePlugin.outbound?.sendMedia;
expect(sendMedia).toBeDefined();
const sendMedia = requireIMessageSendMedia();
const result = await sendMedia!({
const result = await sendMedia({
cfg,
to: "chat_id:77",
text: "caption",
@@ -66,11 +80,10 @@ describe("imessagePlugin outbound", () => {
it("forwards mediaLocalRoots on direct sendMedia adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
const sendMedia = imessagePlugin.outbound?.sendMedia;
expect(sendMedia).toBeDefined();
const sendMedia = requireIMessageSendMedia();
const mediaLocalRoots = ["/tmp/workspace"];
const result = await sendMedia!({
const result = await sendMedia({
cfg,
to: "chat_id:88",
text: "caption",

View File

@@ -1,4 +1,4 @@
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js";
import type { ResolvedIMessageAccount } from "./accounts.js";
import { monitorIMessageProvider } from "./monitor.js";

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