Compare commits

..

653 Commits

Author SHA1 Message Date
Vincent Koc
984a83dd22 fix(docs): use valid Lucide icon names for Mintlify cards
Replace Font Awesome icons with Lucide equivalents:
message -> messages-square, microchip -> cpu, book -> book-open,
gear -> settings, flask -> test-tubes, file-code -> file-json.
Add icons to channel plugin advanced topics cards.
2026-03-22 11:40:46 -07:00
Vincent Koc
b89f9a9a7c fix(docs): address second round of PR review feedback
- Add required model fields (reasoning, input, cost, contextWindow,
  maxTokens) to catalog example (issue b)
- Fix buildChannelConfigSchema to take a Zod schema argument (issue c)
- Replace fabricated setupWizard steps/run with real ChannelSetupWizard
  contract (channel, status, credentials) (issue d)
- Add required sessionFile/workspaceDir to runEmbeddedPiAgent (issue e)
- Fix wrapStreamFn to return StreamFn from ctx.streamFn (issue f)
2026-03-22 11:34:13 -07:00
Vincent Koc
1f4e4028ef fix(docs): use correct createProviderApiKeyAuthMethod options
Replace incorrect params (provider, validate) with actual required fields
(providerId, methodId, optionKey, flagName, promptMessage) matching
src/plugins/provider-api-key-auth.ts.
2026-03-22 09:58:17 -07:00
Vincent Koc
0cd6e6df6f fix(docs): address PR review feedback on plugin SDK pages
- Remove nonexistent api.runtime.channel.handleInboundMessage call,
  replace with realistic webhook pattern and note about channel-specific
  inbound handling (issue a)
- Fix registrationMode values: 'setup' -> 'setup-only' and 'setup-runtime'
  matching actual PluginRegistrationMode type (issue b)
- Fix createOptionalChannelSetupSurface params: channelId -> channel,
  add required label field (issue c)
- Fix broken anchor links: #multi-capability-providers ->
  #step-5-add-extra-capabilities, #plugin-kinds -> #registration-api (issue d)
- Add missing acmeChatApi import in channel plugin example (issue e)
- Fix undefined provider variable in provider test example (issue f)
2026-03-22 09:52:18 -07:00
Vincent Koc
1db4cfd9c8 docs(plugins): add Mintlify components (Steps, CodeGroup, Tabs, Accordion, CardGroup)
- Channel plugin guide: wrap walkthrough in Steps, use CodeGroup for
  package.json/manifest, Accordion for createChatChannelPlugin details,
  CardGroup for advanced topics
- Provider plugin guide: wrap walkthrough in Steps, use CodeGroup for
  package files, Tabs for hook examples, Accordion for all-hooks reference
- Getting started: use CardGroup for plugin-type picker and next steps,
  CodeGroup for package/manifest
- SDK Overview: wrap subpath tables in AccordionGroup for scannability
2026-03-22 09:44:53 -07:00
Vincent Koc
0a8caa6d8b docs(plugins): add SDK reference and how-to guide pages
Create 7 new plugin SDK documentation pages:
- sdk-overview: import map, registration API reference
- sdk-entrypoints: definePluginEntry/defineChannelPluginEntry reference
- sdk-runtime: api.runtime namespace reference
- sdk-setup: packaging, manifests, config schemas reference
- sdk-channel-plugins: step-by-step channel plugin how-to
- sdk-provider-plugins: step-by-step provider plugin how-to
- sdk-testing: test utilities and patterns reference

Restructure plugin docs navigation with nested groups:
- Top-level: user-facing pages (Install, Community, Bundles)
- Building Plugins: Getting Started, Channel, Provider
- SDK Reference: Overview, Entry Points, Runtime, Setup, Testing, Migration, Manifest, Internals

Revise existing pages for new IA:
- building-plugins.md: tightened as quick-start, routes to detailed guides
- architecture.md: updated info box with links to new guides
- sdk-migration.md: expanded Related section
2026-03-22 09:38:38 -07:00
Peter Steinberger
a94ec3b79b fix(security): harden exec approval boundaries 2026-03-22 09:35:25 -07:00
Peter Steinberger
e99d44525a test: refresh unit timing snapshot 2026-03-22 16:34:09 +00:00
Peter Steinberger
1d2dff0c4e test: trim import-heavy startup paths 2026-03-22 16:34:09 +00:00
Peter Steinberger
3fa2300ba1 perf: reduce plugin runtime startup overhead 2026-03-22 16:34:09 +00:00
Peter Steinberger
bb16ab9e08 refactor(synology-chat): split inbound webhook flow 2026-03-22 09:26:50 -07:00
Peter Steinberger
ce19a41f52 fix(synology-chat): scope DM sessions by account 2026-03-22 09:21:05 -07:00
Bob
4f1e12a2b1 Docs: prototype generated plugin SDK reference (#51877)
* Chore: unblock synced main checks

* Docs: add plugin SDK docs implementation plan

* Docs: scaffold plugin SDK reference phase 1

* Docs: mark plugin SDK reference surfaces unstable

* Docs: prototype generated plugin SDK reference

* docs(plugin-sdk): replace generated reference with api baseline

* docs(plugin-sdk): drop generated reference plan

* docs(plugin-sdk): align api baseline flow with config docs

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-22 09:19:00 -07:00
Peter Steinberger
0f2c380bd9 chore: add security triage skill 2026-03-22 09:16:18 -07:00
Peter Steinberger
43d95a2db3 docs: clarify tools invoke operator boundary 2026-03-22 09:14:42 -07:00
Vincent Koc
9b9e1ae901 fix(discord): trim dm allowlist entries (#52354) 2026-03-22 09:13:30 -07:00
Vincent Koc
3ce5a8366a fix(plugins): enforce minimum host versions for installable plugins (#52094)
* fix(plugins): enforce min host versions

* fix(plugins): tighten min host version validation

* chore(plugins): trim dead min host version code

* fix(plugins): handle malformed min host metadata

* fix(plugins): key manifest cache by host version
2026-03-22 09:12:08 -07:00
Vincent Koc
6b7206ed35 perf(inbound): trim reply-run startup imports (#52332)
* perf(inbound): trim reply-run startup imports

* style(reply): format body runtime import

* test(reply): restore runtime seam mocks
2026-03-22 09:05:14 -07:00
Peter Steinberger
05279539a8 docs(plugin-sdk): document public SDK surface 2026-03-22 08:50:48 -07:00
Vincent Koc
e1ff24903f fix(gateway): follow up startup import reviews (#52337) 2026-03-22 08:48:53 -07:00
Vincent Koc
b9e71240ed refactor(doctor): centralize channel capability metadata (#52325)
* refactor(doctor): centralize channel capabilities

* fix(doctor): preserve msteams sender warnings
2026-03-22 08:47:16 -07:00
Vincent Koc
d3a0a623a3 fix(gateway): trim startup plugin imports (#52326)
* fix(gateway): trim startup plugin imports

* fix(gateway): follow up startup import reviews
2026-03-22 08:43:54 -07:00
Peter Steinberger
d1b080eac5 perf: route more vitest files to threads 2026-03-22 08:40:27 -07:00
Peter Steinberger
49091ebcbd chore: update dependencies except carbon 2026-03-22 08:37:03 -07: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
scoootscooob
6516cfa566 refactor: route Slack runtime through plugin sdk (#51766) 2026-03-21 10:11:17 -07:00
Vincent Koc
4e979ea6ca refactor(doctor): extract provider and shared config helpers (#51753)
* refactor(doctor): add shared doctor types

* refactor(doctor): add shared allowlist helpers

* refactor(doctor): extract empty allowlist warnings

* refactor(doctor): extract telegram allowfrom scanning

* refactor(doctor): extract telegram allowfrom repair

* refactor(doctor): extract discord id repair

* refactor(doctor): add shared object helpers

* refactor(doctor): extract mutable allowlist scanning

* refactor(doctor): extract open-policy allowfrom repair

* refactor(doctor): extract allowlist policy repair

* fix(doctor): unblock discord provider refactor checks

* refactor(doctor): fix provider layering in shared warnings
2026-03-21 10:09:38 -07:00
Harold Hunt
e24bf22f98 Fix Discord /codex_resume picker expiration (#51260)
Merged via squash.

Prepared head SHA: 76eb184dbe
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-21 12:59:21 -04:00
Vincent Koc
f4227e2787 fix(ci): remove duplicate embedding default export 2026-03-21 09:39:37 -07:00
Vincent Koc
e61835ec5e fix(ci): restore embedding defaults and plugin boundaries 2026-03-21 09:32:14 -07:00
Pandadadadazxf
2fe0efc9e1 fix: compaction safeguard summary budget (#27727)
Merged via squash.

Prepared head SHA: a7ab64e394
Co-authored-by: Pandadadadazxf <200469161+Pandadadadazxf@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-21 09:27:51 -07:00
Val Alexander
7c520cc0ea web UI: fix context notice using accumulated inputTokens instead of prompt snapshot (#51721)
The context-usage banner in the web UI fell back to inputTokens when
totalTokens was missing. inputTokens is accumulated across all API
calls in a run (tool-use loops, compaction retries), so it overstates
actual context window utilization -- e.g. showing "100% context used
757.3k / 200k" when the real prompt snapshot is only 46k/200k (23%).

Drop the inputTokens fallback so the banner only fires when a genuine
prompt snapshot (totalTokens) is available.

Made-with: Cursor
2026-03-21 11:16:32 -05:00
Vincent Koc
15fd11032d fix(status): skip cold-start status probes 2026-03-21 08:59:19 -07:00
Vincent Koc
a267c5d9ae refactor(doctor): extract telegram provider warnings (#51704)
* refactor(doctor): extract telegram provider warnings

* docs(changelog): note doctor provider refactor
2026-03-21 08:57:06 -07:00
Vincent Koc
a90c5092f2 fix(telegram): default fresh setups to mention-gated groups 2026-03-21 08:54:23 -07:00
Vincent Koc
91b9be1549 docs(changelog): note telegram doctor first-run guidance 2026-03-21 08:46:28 -07:00
Vincent Koc
2ead75ea0e fix(doctor): add telegram first-run guidance 2026-03-21 08:46:28 -07:00
Vincent Koc
7918308b1a fix(doctor): suppress telegram fresh-install group warning 2026-03-21 08:46:28 -07:00
Vincent Koc
06b4efb1e7 fix(configure): drop duplicate wizard header 2026-03-21 08:41:57 -07:00
Vincent Koc
2e8c8a7ae6 refactor(openai): centralize provider defaults 2026-03-21 08:40:03 -07:00
Vincent Koc
931fc9989d docs(changelog): note fresh setup provider guidance 2026-03-21 08:34:44 -07:00
Vincent Koc
4c4eea97e9 fix(configure): tighten fresh setup provider UX 2026-03-21 08:34:44 -07:00
Vincent Koc
ffce904a10 fix(check): format onboard search test 2026-03-21 08:33:15 -07:00
Vincent Koc
a27aeeabf0 docs(changelog): note web search key copy 2026-03-21 08:22:52 -07:00
Vincent Koc
265386cd6b feat(plugins): register claude bundle commands natively 2026-03-21 08:14:48 -07:00
zidongdesign
2b68d20ab3 feat: notify user when context compaction starts and completes (#38805)
Merged via squash.

Prepared head SHA: 0f48c1bbf6
Co-authored-by: zidongdesign <81469543+zidongdesign@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-21 08:14:29 -07:00
Vincent Koc
1169d51aee fix(onboarding): clarify web search key copy 2026-03-21 08:13:39 -07:00
Vincent Koc
1698726c18 chore(deps): dedupe pnpm lockfile 2026-03-21 08:03:42 -07:00
Harold Hunt
5eb99a9b50 Infra: unify plugin split runtime state (#50725)
Merged via squash.

Prepared head SHA: 570b7b9459
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-21 11:00:21 -04:00
Dinakar Sarbada
1643d15057 fix(matrix): pass agentId to buildMentionRegexes for agent-level mention patterns (#51272)
* fix(matrix): pass agentId to buildMentionRegexes for agent-level mention patterns

* fix(matrix): resolve conflicts from main branch

* Retrigger CI

---------

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-03-21 07:43:59 -07:00
Vincent Koc
a3a5cad7d7 fix(onboarding): hide image-only auth providers 2026-03-21 07:42:42 -07:00
scoootscooob
f10d054745 refactor: route discord runtime through plugin sdk (#51444) 2026-03-21 07:40:51 -07:00
Echo
11aff6ed72 fix(auth): support remote Codex OAuth manual input (#51631)
* fix(auth): support remote codex oauth manual input

* fix: support remote Codex OAuth manual input (#51631) (thanks @cash-echo-bot)

---------

Co-authored-by: Cash Williams <cashwilliams@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 19:40:54 +05:30
Bijin
59c4059647 Add Community plugins - Wecom (#29905)
Merged via squash.

Prepared head SHA: da0f08436c
Co-authored-by: sliverp <38134380+sliverp@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-21 22:03:24 +08:00
Tak Hoffman
4af79f20d5 fix ci main cancellation behavior 2026-03-21 08:57:37 -05:00
Harold Hunt
35e6310b22 Docs: refresh generated config baseline (#51625) 2026-03-21 09:41:56 -04:00
fuller-stack-dev
c9449d77b4 feat(gateway): persist webchat inbound images to disk (#51324)
* feat(gateway): persist webchat inbound images to disk

Images sent via the webchat control UI (chat.send RPC) were parsed into
content blocks but never written to disk, unlike WhatsApp and Telegram
handlers which call saveMediaBuffer(). This caused:

- Images lost after conversation compaction (only existed as ephemeral base64)
- Image editing/generation workflows failing for webchat-origin images
- Incomplete ~/.openclaw/media/inbound/ directory

After parseMessageWithAttachments extracts parsedImages, iterate and
persist each via saveMediaBuffer(buffer, mimeType, 'inbound'). Uses
fire-and-forget (.catch + warn log) so disk I/O never blocks the
chat.send response path.

Fixes #47930

* fix(gateway): address PR review comments on webchat image persistence

- Move saveMediaBuffer calls after sendPolicy/stop/dedupe checks so
  rejected or retried requests don't write files to disk (Codex P1)
- Await all saves and collect SavedMedia results into persistedImages
  so the persisted paths are available in scope (Greptile P1)
- Preserve Error stack trace in warn log instead of coercing to
  toString() (Greptile P2)
- Switch to Promise.all for concurrent writes

* fix(gateway): address remaining review comments on webchat image persistence

- Revert to fire-and-forget pattern (no await) to eliminate race window
  where retried requests miss the in-flight guard during image saves
- Remove unused SavedMedia import and persistedImages collection
- Use formatForLog for consistent error logging with stack traces
- Add NOTE comment about path propagation being a follow-up task

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

* fix(gateway): gate image persistence to webchat callers and defer base64 decode

* fix: drop unrelated format churn in lifecycle.test.ts

* gateway: clarify image persistence scope covers all chat.send callers

* fix(gateway): use generic chat.send log prefix for image persistence warnings

* fix(gateway): persist chat.send image refs in transcript

* fix(gateway): keep chat.send image refs off visible text

* fix(gateway): persist chat send media refs on dispatch

* fix(gateway): serialize chat send image persistence

* fix(gateway): persist chat send media after dispatch

* fix: persist chat.send inbound images across follow-ups (#51324) (thanks @fuller-stack-dev)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 19:05:11 +05:30
RichardCao
7bf437402d fix: trim config validation startup imports (#51574)
Merged via squash.

Prepared head SHA: 23b235e465
Co-authored-by: RichardCao <4612401+RichardCao@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-21 09:31:39 -04:00
Tak Hoffman
6db6e117df fix(ui): use current session context usage in chat notice 2026-03-21 08:18:27 -05:00
Harold Hunt
8d733350de CI: run config docs drift check on PRs (#51618) 2026-03-21 09:13:41 -04:00
fuller-stack-dev
5137a51307 feat(github-copilot): resolve any model ID dynamically (#51325)
* feat(github-copilot): resolve any model ID dynamically instead of only known ones

* refactor(github-copilot): extract model resolution, add reasoning heuristic and tests

* fix(github-copilot): default synthetic models to text-only input

* ci: retrigger checks

* copilot: mark synthetic catch-all models as vision-capable

* fix(github-copilot): anchor reasoning regex, unexport internal constants, add mid-string test

* fix(github-copilot): default synthetic models to text-only input

* fix(github-copilot): restore image input for synthetic models with explanatory comment

* fix(github-copilot): normalize registry lookup casing, add bare o3 test case

* fix: preserve configured overrides for dynamic models

* fix: allow dynamic GitHub Copilot models (#51325) (thanks @fuller-stack-dev)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 17:37:50 +05:30
Ayaan Zaidi
42e708d005 fix(agents): register simple completion transports 2026-03-21 17:22:31 +05:30
Taras Lukavyi
466debb75c feat(telegram): auto-rename DM topics on first message (#51502)
* feat(telegram): auto-rename DM topics on first message

fix(telegram): use bot.api for topic rename to avoid SecretRef resolution

* fix(telegram): address security + test review feedback

- Fix test assertion: DEFAULT_PROMPT_SUBSTRING matches 'very short'
- Use RawBody instead of Body (no envelope metadata to LLM)
- Truncate user message to 500 chars for LLM prompt
- Remove user-derived content from verbose logs
- Remove redundant threadSpec.id null check
- Fix AutoTopicLabelParams type to match generateTopicLabel

* fix(telegram): use effective dm auto-topic config

* fix(telegram): detect direct auto-topic overrides

* fix: auto-rename Telegram DM topics on first message (#51502) (thanks @Lukavyi)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 16:53:30 +05:30
darkamenosa
cdf49f0b00 zalo: use photo_url for inbound images (#51543)
* Zalo: use photo_url for inbound images

* Tests: wait for Zalo webhook image processing
2026-03-21 17:21:44 +07:00
Josh Avant
3f7f2c8dc9 Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500)
* voice-call: harden streaming startup and fallback behavior

* voice-call: suppress barge-in during intro

* voice-call: skip first-turn auto-response while greeting plays

* Voice-call: improve telephony audio fidelity and pacing

* voice-call: enforce spoken JSON and first-message barge skip

* voice-call: fix silent stream TTS regression

* voice-call: remove TTS timing diagnostics and document stream behavior

* voice-call: fail stream playback when stream sends are dropped

* voice-call: harden spoken contract and initial stream replay

* voice-call: suppress barge transcripts during initial greeting

* voice-call: harden stream fallback and media safety
2026-03-21 04:15:16 -05:00
Val Alexander
e9f715f27b UI: fix and optimize overview log panels (#51477) 2026-03-21 02:14:53 -05:00
Val Alexander
2fd372836e iOS: improve QR pairing flow (#51359)
- improve QR pairing UX and bootstrap token handling
- preserve repeated optimistic user messages during refresh
- add regression coverage for refresh reconciliation

Thanks @ImLukeF
2026-03-21 01:10:29 -05:00
Ayaan Zaidi
ce6a48195a test: fix whatsapp config-runtime mock store path 2026-03-21 11:39:21 +05:30
Ayaan Zaidi
8a05c05596 fix: defer plugin runtime globals until use 2026-03-21 11:14:48 +05:30
scoootscooob
43513cd1df test: refresh plugin import boundary baseline (#51434) 2026-03-20 22:36:11 -07:00
Ted Li
5bb5d7dab4 CLI: respect full timeout for loopback gateway probes (#47533)
* CLI: respect loopback gateway probe timeout

* CLI: name gateway probe budgets

* CLI: keep inactive loopback probes fast

* CLI: inline simple gateway probe caps

* Update helpers.ts

* Gateway: clamp probe timeout to timer-safe max

* fix: note loopback gateway probe timeout fix (#47533) (thanks @MonkeyLeeT)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 10:57:50 +05:30
scoootscooob
9fb78453e0 fix(discord): clarify startup readiness log (#51425)
Merged via squash.

Prepared head SHA: 390986dc47
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-20 22:00:09 -07:00
scoootscooob
d78e13f545 fix(agent): clarify embedded transport errors (#51419)
Merged via squash.

Prepared head SHA: cea32a4bda
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-20 21:47:47 -07:00
Cypherm
6b4c24c2e5 feat(telegram): support custom apiRoot for alternative API endpoints (#48842)
* feat(telegram): support custom apiRoot for alternative API endpoints

Add `apiRoot` config option to allow users to specify custom Telegram Bot
API endpoints (e.g., self-hosted Bot API servers). Threads the configured
base URL through all Telegram API call sites: bot creation, send, probe,
audit, media download, and api-fetch. Extends SSRF policy to dynamically
trust custom apiRoot hostname for media downloads.

Closes #28535

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(telegram): thread apiRoot through allowFrom lookups

* fix(telegram): honor lookup transport and local file paths

* refactor(telegram): unify username lookup plumbing

* fix(telegram): restore doctor lookup imports

* fix: document Telegram apiRoot support (#48842) (thanks @Cypherm)

---------

Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 10:10:38 +05:30
wesley
598f1826d8 fix(subagent): include partial progress when subagent times out (#40700)
* fix(subagent): preserve timeout partial progress reporting

* refactor: unify subagent output selection

* test: cover distilled subagent timeout output

* fix: remove timeout-only subagent path

---------

Co-authored-by: Wesley <imwyvern@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 08:44:38 +05:30
Tyler Yust
5e417b44e1 Outbound: skip broadcast channel scan when channel is explicit 2026-03-20 18:21:01 -07:00
Tyler Yust
b71686ab44 Enhance web search provider config validation and compatibility handling
- Added a test to ensure no warnings for legacy Brave config when bundled web search allowlist compatibility is applied.
- Updated validation logic to incorporate compatibility configuration for bundled web search plugins.
- Refactored the ensureRegistry function to utilize the new compatibility handling.
2026-03-20 18:20:50 -07:00
Vincent Koc
c3be293dd5 fix(slack): unify slash conversation-runtime mock 2026-03-20 18:19:07 -07:00
Danh Doan
e78129a4d9 feat(context-engine): pass incoming prompt to assemble (#50848)
Merged via squash.

Prepared head SHA: 282dc9264d
Co-authored-by: danhdoan <12591333+danhdoan@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-20 17:03:21 -07:00
Sally O'Malley
6a6f1b5351 changelog (#51322)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-20 19:30:33 -04:00
Josh Lehman
751d5b7849 feat: add context engine transcript maintenance (#51191)
Merged via squash.

Prepared head SHA: b42a3c28b4
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-20 16:28:27 -07:00
Peter Steinberger
6526074c85 test: trim singleton cold-start reloads 2026-03-20 23:14:28 +00:00
Peter Steinberger
0a842de354 test: widen low-profile singleton batching 2026-03-20 23:02:33 +00:00
Josh Lehman
2364e45fe4 test: align extension runtime mocks with plugin-sdk (#51289)
* test: align extension runtime mocks with plugin-sdk

Update stale extension tests to mock the plugin-sdk runtime barrels that production code now imports, and harden the Signal tool-result harness around system-event assertions so the channels lane matches current extension boundaries.

Regeneration-Prompt: |
  Verify the failing channels-lane tests against current origin/main in an isolated worktree before changing anything. If the failures reproduce on main, keep the fix test-only unless production behavior is clearly wrong. Recent extension refactors moved Telegram, WhatsApp, and Signal code onto plugin-sdk runtime barrels, so update stale tests that still mock old core module paths to intercept the seams production code now uses. For Signal reaction notifications, avoid brittle assertions that depend on shared queued system-event state when a direct harness spy on enqueue behavior is sufficient. Preserve scope: only touch the failing tests and their local harness, then rerun the reproduced targeted tests plus the full channels lane and repo check gate.

* test: fix extension test drift on main

* fix: lazy-load bundled web search plugin registry

* test: make matrix sweeper failure injection portable

* fix: split heavy matrix runtime-api seams

* fix: simplify bundled web search id lookup

* test: tolerate windows env key casing
2026-03-20 15:59:53 -07:00
Vincent Koc
e635cedb85 test(openai): cover bundle media surfaces 2026-03-20 15:53:12 -07:00
Vincent Koc
d54ebed7c8 test(openai): add plugin entry live coverage 2026-03-20 15:53:12 -07:00
Vincent Koc
d1d46c6cfb test(openai): broaden live model coverage 2026-03-20 15:53:12 -07:00
Vincent Koc
f1802a5bc7 test(openai): add live provider probe 2026-03-20 15:53:12 -07:00
Sally O'Malley
6e20c4baa0 feat: add anthropic-vertex provider for Claude via GCP Vertex AI (#43356)
Reuse pi-ai's Anthropic client injection seam for streaming, and add
the OpenClaw-side provider discovery, auth, model catalog, and tests
needed to expose anthropic-vertex cleanly.

Signed-off-by: sallyom <somalley@redhat.com>
2026-03-20 18:48:42 -04:00
Vincent Koc
42ca447189 test(openrouter): add live plugin coverage 2026-03-20 15:36:34 -07:00
Peter Steinberger
fac64c2392 test: widen unit timing snapshot coverage 2026-03-20 22:33:49 +00:00
Peter Steinberger
39a4fe576d test: normalize perf manifest paths 2026-03-20 22:06:46 +00:00
Josh Lehman
c3972982b5 fix: sanitize malformed replay tool calls (#50005)
Merged via squash.

Prepared head SHA: 64ad5563f7
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-20 15:03:30 -07:00
Peter Steinberger
cadbaa34c1 test: widen low-profile scheduler peeling 2026-03-20 21:30:44 +00:00
Peter Steinberger
994b42a5a5 test: parallelize safe audit case tables 2026-03-20 21:16:01 +00:00
Peter Steinberger
aed1f6d807 test: parallelize low-profile deferred lanes 2026-03-20 21:07:56 +00:00
Peter Steinberger
09cf6d80ec test: batch thread-only unit lanes 2026-03-20 20:51:38 +00:00
Josh Avant
7abfff756d Exec: harden host env override handling across gateway and node (#51207)
* Exec: harden host env override enforcement and fail closed

* Node host: enforce env override diagnostics before shell filtering

* Env overrides: align Windows key handling and mac node rejection
2026-03-20 15:44:15 -05:00
Josh Avant
c7134e629c LINE: harden Express webhook parsing to verified raw body (#51202)
* LINE: enforce signed-raw webhook parsing

* LINE: narrow scope and add buffer regression

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-20 15:32:55 -05:00
Vincent Koc
11d71ca352 pairing: keep setup codes bootstrap-token only (#51259) 2026-03-20 13:27:39 -07:00
Peter Steinberger
5a5e84ca1d test: drop duplicate web search helper 2026-03-20 20:25:24 +00:00
Peter Steinberger
fa71ad7c5d test: repair latest-main web search regressions 2026-03-20 20:17:11 +00:00
Josh Lehman
23fef04c4e test: fix setup finalize web search mocks (#51253) 2026-03-20 13:07:22 -07:00
Peter Steinberger
1b18742e8e test: peel more slow unit files out of unit-fast 2026-03-20 20:04:52 +00:00
Teddy Tennant
a20ba74978 test: add SSRF guard coverage for URL credential bypass vectors (#50523)
* security: add SSRF guard tests for URL credential bypass vectors

* test(security): strengthen SSRF redirect guard coverage

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-20 12:45:06 -07:00
Gustavo Madeira Santana
3da66718f4 Web: derive search provider metadata from plugin contracts (#50935)
Merged via squash.

Prepared head SHA: e1c7d72833
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-20 12:41:04 -07:00
Peter Steinberger
acf32287b4 test: trim more extension startup from unit tests 2026-03-20 19:28:32 +00:00
Jaaneek
916f496b51 Add Grok 4.20 reasoning and non-reasoning to xAI model catalog (#50772)
Merged via squash.

Prepared head SHA: 095e645ea5
Co-authored-by: Jaaneek <25470423+Jaaneek@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-20 15:28:30 -04:00
Peter Steinberger
f6b3245a7b fix: pass full sdk gate 2026-03-20 19:24:10 +00:00
Peter Steinberger
62ddc9d9e0 refactor: consolidate plugin sdk surface 2026-03-20 19:24:10 +00:00
Vincent Koc
46854a84a4 test(plugin-sdk): cover legacy root diagnostic listeners 2026-03-20 12:23:02 -07:00
Peter Steinberger
7b00a0620a test: stabilize gateway alias coverage 2026-03-20 19:17:44 +00:00
Gustavo Madeira Santana
a05da76718 Matrix: dedupe replayed inbound events on restart (#50922)
Merged via squash.

Prepared head SHA: 10d9770aa6
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-20 12:13:24 -07:00
Vincent Koc
5408a3d1a4 docs(contributing): clarify accepted PR scope 2026-03-20 12:04:16 -07:00
Peter Steinberger
39053bddd7 test: decouple zalo outbound payload contract from channel runtime 2026-03-20 19:02:07 +00:00
Peter Steinberger
a7401366ef test: trim more channel-heavy startup in unit tests 2026-03-20 18:50:52 +00:00
Vincent Koc
083f825122 docs: expand community plugins (always visible), add Codex App Server/Lossless Claw/Opik, A-Z order 2026-03-20 11:40:50 -07:00
Peter Steinberger
b26edfe1ff test: trim plugin-heavy unit test imports 2026-03-20 18:35:39 +00:00
Vincent Koc
740b345a2e docs: sort Tools nav group alphabetically 2026-03-20 11:33:51 -07:00
Vincent Koc
483926a6fb docs: rewrite sdk-migration and bundles, fold agent-tools into building-plugins, remove cookbook from nav, remove dead WeChat listing 2026-03-20 11:32:11 -07:00
Vincent Koc
2e0b445b46 docs: use expandable Accordions for community plugins, keep A-Z order 2026-03-20 11:27:45 -07:00
Tak Hoffman
16e055c083 restore extension-api backward compatibility with migration warning 2026-03-20 13:27:30 -05:00
Vincent Koc
e4d0fdcc15 docs: rewrite community plugins page with Cards, Steps, and quality bar table 2026-03-20 11:23:46 -07:00
Vincent Koc
fb293fa36f docs: rewrite plugins install/configure page with Steps, Accordions, and clear hierarchy 2026-03-20 11:20:36 -07:00
Vincent Koc
a4a5ed8948 docs: retitle plugin internals/agent-tools/cookbook, collapse Browser into Tools, reorder Plugins group 2026-03-20 11:17:49 -07:00
Vincent Koc
4edab304db docs: reorder Tools & Plugins nav, move Media/devices to Gateway tab, rewrite 4 problem pages with Mintlify components 2026-03-20 11:10:45 -07:00
Vincent Koc
3d097f1052 docs: rewrite tools landing page with Tools/Skills/Plugins explainer using Steps 2026-03-20 11:02:01 -07:00
Vincent Koc
e18ab85f08 docs(agents): clarify plugin nomenclature 2026-03-20 10:59:29 -07:00
Vincent Koc
5f600e117d docs: restructure Tools & Plugins section, rename building-extensions to building-plugins, rewrite tools landing page and SDK migration 2026-03-20 10:55:56 -07:00
Ayaan Zaidi
35ac1f6e07 fix: add changelog for telegram account routing fix (#50853) (thanks @hclsys) 2026-03-20 23:24:40 +05:30
HCL
4e45a663e7 fix(telegram): prevent silent wrong-bot routing when accountId not in config
When a non-default accountId is specified but not found in the accounts
config, resolveTelegramToken() falls through to channel-level defaults
(botToken, tokenFile, env) — silently routing messages via the wrong
bot's token. This is a cross-bot message leak with no error or warning.

Root cause: extensions/telegram/src/token.ts:44-46, resolveAccountCfg()
returns undefined for unknown accountIds but code continues to fallbacks.
Introduced in e5bca0832f when Telegram moved to extensions/.

Fix: return { token: "", source: "none" } with a diagnostic log when
a non-default accountId is not found. Existing behavior for known
accounts (with or without per-account tokens) preserved.

Test: added "does not fall through when non-default accountId not in
config" — 1/1 new, 10/10 existing unaffected.

Closes #49383

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-20 23:24:40 +05:30
Vincent Koc
c64893a9c2 fix(config): use static channel metadata in docs baseline (#51161) 2026-03-20 10:52:40 -07:00
Vincent Koc
ad4536fd7e docs: rename Extensions to Plugins, rewrite building guide as capability-agnostic, move voice-call to Channels 2026-03-20 10:45:56 -07:00
Peter Steinberger
1cabb053ad test: lazy-load default setup registry 2026-03-20 17:43:49 +00:00
Vincent Koc
23a119c6ea test(msteams): clear remaining rebase conflict hunk 2026-03-20 10:38:55 -07:00
Vincent Koc
42801f6178 fix(plugin-sdk): dedupe rebased zalo export entries 2026-03-20 10:38:55 -07:00
Vincent Koc
5b7ae24e30 test(msteams): align adapter doubles with interfaces 2026-03-20 10:38:55 -07:00
Vincent Koc
a2e1991ed3 refactor(plugin-sdk): route bundled runtime barrels through public subpaths 2026-03-20 10:38:55 -07:00
Vincent Koc
fb3550ef5e test(sessions): stabilize pruning integration setup 2026-03-20 10:38:55 -07:00
Vincent Koc
58889f984f docs: set sidebar title to SDK Migration 2026-03-20 10:32:51 -07:00
Vincent Koc
06311f89e0 docs: escape angle brackets in sdk-migration to fix Mintlify MDX build 2026-03-20 10:32:01 -07:00
Peter Steinberger
fa275fddf8 docs: refresh config baseline 2026-03-20 17:29:37 +00:00
Vincent Koc
96e1c37685 docs: improve Building Extensions with Mintlify Steps, Accordion, and Warning components 2026-03-20 10:24:51 -07:00
Vincent Koc
a39c440d39 fix(config): share json compatibility parsing 2026-03-20 10:17:53 -07:00
Harold Hunt
4838e3934b Tests: default CI unit lanes to forks (#51145) 2026-03-20 13:15:55 -04:00
Saurabh Mishra
4266e260e1 fix: emit message:sent hook on Telegram streaming preview finalization (#50917)
* fix: emit message:sent hook on Telegram streaming preview finalization

* fix: include messageId in preview-delivered hook callback

* fix: skip message:sent hook for preview-retained paths

* fix: correct JSDoc for onPreviewDelivered callback

* fix: pass visible preview text on regressive-skip path

* fix: remove dead fallbacks and add stopCreatesFirstPreview test

* Update extensions/telegram/src/lane-delivery-text-deliverer.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: align telegram preview sent hooks (#50917) (thanks @bugkill3r)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-20 22:42:04 +05:30
Peter Steinberger
85a5d64d8f test: speed up isolated test lanes 2026-03-20 17:11:23 +00:00
Vincent Koc
93fbe26adb fix(config): tighten json and json5 parsing paths (#51153) 2026-03-20 10:10:57 -07:00
Vincent Koc
87eeab7034 docs: add plugin SDK migration guide, link deprecation warning to docs 2026-03-20 10:05:06 -07:00
Peter Steinberger
fcabecc9a4 fix: remove duplicate plugin sdk exports 2026-03-20 16:52:10 +00:00
Peter Steinberger
18fa2992f9 fix: restore plugin sdk runtime barrels 2026-03-20 16:46:34 +00:00
Peter Steinberger
cb89325cd8 fix: restore latest main gate 2026-03-20 16:46:34 +00:00
Peter Steinberger
4c614c230d fix: restore local gate 2026-03-20 16:46:14 +00:00
Vincent Koc
aa78a0c00e refactor(plugin-sdk): formalize runtime contract barrels 2026-03-20 09:30:34 -07:00
Vincent Koc
9b6f286ac2 refactor(channels): share route format and binding helpers 2026-03-20 09:30:34 -07:00
Vincent Koc
faa9faa767 refactor(web-search): share provider clients and config helpers 2026-03-20 09:30:34 -07:00
Vincent Koc
d3ffa1e4e7 refactor(errors): share api error payload parsing 2026-03-20 09:30:33 -07:00
Vincent Koc
dbc9d3dd70 fix(plugin-sdk): restore root diagnostic compat 2026-03-20 09:27:37 -07:00
Peter Steinberger
50ce9ac1c6 refactor: privatize bundled sdk facades 2026-03-20 15:56:14 +00:00
Peter Steinberger
f6948ce405 refactor: shrink sdk helper surfaces 2026-03-20 15:43:14 +00:00
Peter Steinberger
ba1bb8505f refactor: install optional channels for directory 2026-03-20 15:37:56 +00:00
sudie-codes
06845a1974 fix(msteams): resolve Graph API chat ID for DM file uploads (#49585)
Fixes #35822 — Bot Framework conversation.id format is incompatible with
Graph API /chats/{chatId}. Added resolveGraphChatId() to look up the
Graph-native chat ID via GET /me/chats, cached in the conversation store.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:08:26 -05:00
sudie-codes
7c3af3726f msteams: extend MSTeamsAdapter and MSTeamsActivityHandler types; implement self() (#49929)
- Add updateActivity/deleteActivity to MSTeamsAdapter
- Add onReactionsAdded/onReactionsRemoved to MSTeamsActivityHandler
- Implement directory self() to return bot identity from appId credential
- Add tests for self() in channel.directory.test.ts
2026-03-20 10:08:23 -05:00
sudie-codes
897cda7d99 msteams: fix sender allowlist bypass when route allowlist is configured (GHSA-g7cr-9h7q-4qxq) (#49582)
When a route-level (teams/channel) allowlist was configured but the sender
allowlist (allowFrom/groupAllowFrom) was empty, resolveSenderScopedGroupPolicy
would downgrade the effective group policy from "allowlist" to "open", allowing
any Teams user to interact with the bot.

The fix: when channelGate.allowlistConfigured is true and effectiveGroupAllowFrom
is empty, preserve the configured groupPolicy ("allowlist") rather than letting
it be downgraded to "open". This ensures an empty sender allowlist with an active
route allowlist means deny-all rather than allow-all.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:08:19 -05:00
John Scianna
5607da90d5 feat: pass modelId to context engine assemble() (#47437)
Merged via squash.

Prepared head SHA: d708ddb222
Co-authored-by: jscianna <9017016+jscianna@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-20 08:05:02 -07:00
Johnson Shi
dc86b6d72a docs(azure): replace ARM template deployment with pure az CLI commands (#50700)
* docs(azure): replace ARM template deployment with pure az CLI commands

Rewrites the Azure install guide to use individual az CLI commands
instead of referencing ARM templates in infra/azure/templates/ (removed
upstream). Each Azure resource (NSG, VNet, subnets, VM, Bastion) is now
created with explicit az commands, preserving the same security posture
(Bastion-only SSH, no public IP, NSG hardening).

Also addresses BradGroux review feedback from #47898:
- Add cost considerations section (Bastion ~$140/mo, VM ~$55/mo)
- Add cleanup/teardown section (az group delete)
- Remove stale /install/azure/azure redirect from docs.json

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): split into multiple Steps blocks for richer TOC

Add Quick path and What you need sections. Split the single Steps
block into three (Configure deployment, Deploy Azure resources,
Install OpenClaw) so H2 headers appear in the Mintlify sidebar TOC.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): remove Quick path section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): fix cost section LaTeX rendering, remove comparison

Escape dollar signs to prevent Mintlify LaTeX interpretation.
Also escape underscores in VM SKU name within bold text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): add caveat that deallocated VM stops Gateway

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): simplify install step with clearer description

Download then run pattern (no sudo). Clarify that installer handles
Node LTS, dependencies, OpenClaw install, and onboarding wizard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): add Bastion provisioning latency note

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): use deployment variables in cost and cleanup sections

Replace hardcoded rg-openclaw/vm-openclaw with variables in
deallocate/start and group delete commands so users who customized
names in step 3 get correct commands.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(azure): fix formatting (oxfmt)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 09:23:21 -05:00
Fabian Williams
99e53612cb docs: add delegate architecture guide for organizational deployments (#43261)
* docs: add delegate architecture guide for organizational deployments

Adds a guide for running OpenClaw as a named delegate for organizations.
Covers three capability tiers (read-only, send-on-behalf, proactive),
M365 and Google Workspace delegation setup, security guardrails, and
integration with multi-agent routing.

AI-assisted: Claude Code (Opus 4.6)
Based on: Production deployment at a 501(c)(3) nonprofit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: address review — add Google DWD warning, fix canvas in deny list

- Add security warning for Google Workspace domain-wide delegation
  matching the existing M365 application access policy warning
- Add "canvas" to the security guardrails tool deny list for
  consistency with the full example and multi-agent.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix Tier 1 description to match read-only permissions

Remove "draft replies (saved to Drafts folder)" from Tier 1 since
saving drafts requires write access. Tier 1 is strictly read-only —
the agent summarizes and flags via chat, human acts on the mailbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix oxfmt formatting for delegate-architecture and docs.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix broken links to /automation/standing-orders

Standing orders is a deployment pattern, not an existing doc page.
Replaced with inline descriptions and links to /automation/cron-jobs
and #security-guardrails anchor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: move hardening to prerequisites before identity provider setup

Restructure per community feedback: isolation, tool restrictions,
sandbox, hard blocks, and audit trail now come BEFORE granting any
credentials. The most dangerous step (tenant-wide permissions) no
longer precedes the most important step (scoping and isolation).

Also strengthened M365 and Google Workspace security warnings with
actionable verification steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add standing orders guide and fix broken links

Add docs/automation/standing-orders.md covering:
- Why standing orders (agent autonomy vs human bottleneck)
- Anatomy of a standing order (scope, triggers, gates, escalation)
- Integration with cron jobs for time-based enforcement
- Execute-Verify-Report pattern for execution discipline
- Three production-tested examples (content, finance, monitoring)
- Multi-program architecture for complex agents
- Best practices (do's and don'ts)

Update delegate-architecture.md to link standing orders references
to the new page instead of dead links.

Add standing-orders to Automation nav group in docs.json (en + zh-CN).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: address review feedback on standing-orders

- P1: Clarify that standing orders should go in AGENTS.md (auto-injected)
  rather than arbitrary subdirectory files. Add Tip callout explaining
  which workspace files are bootstrapped.
- P2: Remove dead /concepts/personality-files link, replace with
  /concepts/agent-workspace which covers bootstrap files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:23:17 -05:00
Thirumalesh
c6968c39d6 feat(compaction): truncate session JSONL after compaction to prevent unbounded growth (#41021)
Merged via squash.

Prepared head SHA: fa50b63580
Co-authored-by: thirumaleshp <85149081+thirumaleshp@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-20 07:15:09 -07:00
Ayaan Zaidi
4c60956d8e build(android): update Gradle tooling 2026-03-20 17:12:10 +05:30
Ayaan Zaidi
3bda64f75c perf(android): reduce tab-switch CPU churn 2026-03-20 17:10:18 +05:30
caesargattuso
57f1cf66ad fix(gateway): skip seq-gap broadcast for stale post-lifecycle events (#43751)
* fix: stop stale gateway seq-gap errors (#43751) (thanks @caesargattuso)

* fix: keep agent.request run ids session-scoped

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-20 14:56:54 +05:30
Bijin
192f859325 Add Community plugins - openclaw-dingtalk (#29913)
Merged via squash.

Prepared head SHA: e8e99997cb
Co-authored-by: sliverp <38134380+sliverp@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-20 16:58:51 +08:00
Bijin
6cb2fc501a Community plugins - Add QQbot (#29898)
Merged via squash.

Prepared head SHA: c776a12d15
Co-authored-by: sliverp <38134380+sliverp@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-20 16:51:32 +08:00
Vincent Koc
df536c3248 test(signal): harden tool-result infra-runtime mock 2026-03-20 01:33:16 -07:00
Vincent Koc
d774b3f274 fix(ci): isolate jiti-mocked test files 2026-03-20 01:24:32 -07:00
Vincent Koc
dc06e4fd22 ci: collapse extra workflow guards into check-additional 2026-03-20 01:20:12 -07:00
Vincent Koc
0fae764f10 test(plugins): use sync jiti regression path 2026-03-20 01:12:05 -07:00
Vincent Koc
95f890a8b2 test(plugins): relax jiti error string assertions 2026-03-20 01:07:29 -07:00
Vincent Koc
f0a0a6a5b4 test(plugins): isolate git path alias regression 2026-03-20 00:57:25 -07:00
Vincent Koc
68a274c7b3 fix(ci): isolate loader git-path regression env roots 2026-03-20 00:43:03 -07:00
Vincent Koc
d25f6f1833 fix(ci): restore full loader regression coverage 2026-03-20 00:38:11 -07:00
Vincent Koc
f1e012e0fc fix(telegram): serialize thread binding persists 2026-03-20 00:30:11 -07:00
Vincent Koc
9f8af3604d fix(ci): split slow plugin loader regression test 2026-03-20 00:28:04 -07:00
Vincent Koc
faa8e27291 fix(ci): share compat matrix and restore skill python gating 2026-03-20 00:27:50 -07:00
Ayaan Zaidi
8ac4d13a6f style(docs): format plugin table 2026-03-20 12:56:32 +05:30
Ayaan Zaidi
0c2e6fe97f ci(android): use explicit flavor debug tasks 2026-03-20 12:55:52 +05:30
Ayaan Zaidi
f09f98532c feat(android): hide restricted capabilities in play builds 2026-03-20 12:45:25 +05:30
Ayaan Zaidi
ecec0d5b2c build(android): add play and third-party release flavors 2026-03-20 12:45:25 +05:30
Vincent Koc
dfc157e1a2 test(plugins): trim loader regression harness churn 2026-03-20 00:06:12 -07:00
Vincent Koc
3a72d2d6de fix(config): split config doc baseline coverage 2026-03-20 00:06:12 -07:00
Vincent Koc
e56dde815e fix(web-search): split runtime provider resolution 2026-03-20 00:06:12 -07:00
Vincent Koc
397b0d85f5 fix(tui): split assistant error formatting seam 2026-03-20 00:06:12 -07:00
Saurabh Mishra
709c730e2a fix: standardize 'MS Teams' to 'Microsoft Teams' across docs (#50863)
* fix: standardize 'MS Teams' to 'Microsoft Teams' across docs

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:54:47 -07:00
Vincent Koc
a562fb5550 refactor(web-search): share scoped provider config plumbing 2026-03-19 23:52:53 -07:00
Vincent Koc
96f21c37b4 fix(tools): persist remaining doctor compatibility aliases 2026-03-19 23:42:53 -07:00
Vincent Koc
6c7526f8a0 fix(web-search): share unsupported filter handling 2026-03-19 23:41:02 -07:00
Vincent Koc
ce878a9eb1 fix(test): batch unit-fast worker lifetimes 2026-03-19 23:30:48 -07:00
Vincent Koc
36a59d5c79 fix(discord): drop stale carbon deploy option 2026-03-19 23:30:48 -07:00
Vincent Koc
9af42c6590 fix(config): persist doctor compatibility migrations 2026-03-19 23:28:11 -07:00
Shakker
098a0d0d0d chore(docs): refresh generated config baseline 2026-03-20 06:17:08 +00:00
Shakker
f2849c2417 fix(feishu): stabilize lifecycle replay tests 2026-03-20 06:13:27 +00:00
Shakker
8d805a02fd fix(zalouser): decouple tests from zca-js runtime 2026-03-20 06:13:27 +00:00
Shakker
5036ed2699 fix(secrets): cover tavily in runtime coverage tests 2026-03-20 06:13:27 +00:00
Shakker
06fc498d54 chore(docs): refresh secretref credential matrix 2026-03-20 06:13:27 +00:00
Shakker
94ab044387 fix(ci): split unit-fast into bounded shared-worker lanes 2026-03-20 06:13:27 +00:00
Shakker
4d9ae5899d chore(ci): refresh Linux unit memory hotspots from PR failures 2026-03-20 06:13:27 +00:00
Shakker
b90eef50ec fix(ci): widen Linux memory-hotspot isolation cap 2026-03-20 06:13:27 +00:00
Shakker
829beced04 fix(ci): avoid Windows shell arg overflow in unit-fast 2026-03-20 06:13:27 +00:00
Shakker
3db2cfef07 chore(ci): refresh unit memory hotspot manifest 2026-03-20 06:13:27 +00:00
Shakker
d689b3fc89 fix(ci): prioritize memory-heavy unit scheduling 2026-03-20 06:13:27 +00:00
Shakker
254ea0c65e fix(ci): parse GitHub Actions memory hotspot logs 2026-03-20 06:13:27 +00:00
Shakker
9c7da58770 fix(ci): auto-isolate memory-heavy unit tests 2026-03-20 06:13:27 +00:00
Shakker
fe863c5400 chore(ci): seed unit memory hotspot manifest 2026-03-20 06:13:27 +00:00
Ayaan Zaidi
a73e517ae3 build(protocol): regenerate swift talk models 2026-03-20 11:12:53 +05:30
Ayaan Zaidi
2afd65741c fix: preserve talk provider and speaking state 2026-03-20 11:08:21 +05:30
Ayaan Zaidi
61965e500f fix: route Android Talk synthesis through the gateway (#50849) 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
47e412bd0b fix(review): preserve talk directive overrides 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
4a0341ed03 fix(review): address talk cleanup feedback 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
4386a0ace8 refactor(android): remove legacy elevenlabs talk stack 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
e3afaca1a6 refactor(android): route talk playback through gateway 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
f7fe75a68b refactor(android): simplify talk config parsing 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
4ac355babb feat(gateway): add talk speak rpc 2026-03-20 11:01:24 +05:30
Ayaan Zaidi
84ee6fbb76 feat(tts): add in-memory speech synthesis 2026-03-20 11:01:24 +05:30
Lakshya Agarwal
b36e456b09 feat: add Tavily as a bundled web search plugin with search and extract tools (#49200)
Merged via squash.

Prepared head SHA: ece9226e88
Co-authored-by: lakshyaag-tavily <266572148+lakshyaag-tavily@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-20 01:06:26 -04:00
Gustavo Madeira Santana
914fc265c5 Docs(matrix): add changelog entry for allowBots/allowPrivateNetwork 2026-03-20 00:22:52 -04:00
Gustavo Madeira Santana
1ba70c3707 Docs: switch MiniMax defaults to M2.7 2026-03-20 00:05:04 -04:00
ernestodeoliveira
80110c550f fix(telegram): warn when setup leaves dmPolicy as pairing without allowFrom (#50710)
* fix(telegram): warn when setup leaves dmPolicy as pairing without allowFrom

* fix(telegram): scope setup warning to account config

* fix(telegram): quote setup allowFrom example

* fix: warn on insecure Telegram setup defaults (#50710) (thanks @ernestodeoliveira)

---------

Co-authored-by: Claude Code <claude-code@openclaw.ai>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-20 09:29:33 +05:30
Shakker
991eb2ef03 fix(ci): isolate missing unit-fast heap hotspots 2026-03-20 03:50:46 +00:00
Shakker
4aef83016f fix(matrix): mock configured bot ids in monitor tests 2026-03-20 03:50:06 +00:00
Shakker
03c86b3dee fix(secrets): mock bundled web search providers in runtime tests 2026-03-20 03:48:13 +00:00
Shakker
62e6eb117e chore(docs): refresh generated config baseline 2026-03-20 03:34:11 +00:00
Shakker
218f8d74b6 fix(secrets): use bundled web search fast path during reload 2026-03-20 03:28:08 +00:00
Shakker
2d24f35016 fix(plugins): add bundled web search provider metadata 2026-03-20 03:28:08 +00:00
Gustavo Madeira Santana
9c21637fe9 Docs: clarify Matrix private-network homeserver setup 2026-03-19 23:24:51 -04:00
Gustavo Madeira Santana
f62be0ddcf Matrix: guard private-network homeserver access 2026-03-19 23:24:50 -04:00
Gustavo Madeira Santana
ab97cc3f11 Matrix: add allowBots bot-to-bot policy 2026-03-19 23:24:50 -04:00
Josh Avant
de9f2dc227 Gateway: harden OpenResponses file-context escaping (#50782) 2026-03-19 22:02:13 -05:00
Jinhao Dong
4f00b3b534 feat(xiaomi): add MiMo V2 Pro and MiMo V2 Omni models, switch to OpenAI completions API (#49214)
Merged via squash.

Prepared head SHA: 6b672f36cf
Co-authored-by: DJjjjhao <50042705+DJjjjhao@users.noreply.github.com>
Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com>
Reviewed-by: @grp06
2026-03-19 19:26:47 -07:00
Harold Hunt
f1ce679929 Discord: reconcile native commands without restart churn (#46597)
Merged via squash.

Prepared head SHA: 37090daad4
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-19 22:23:21 -04:00
Harold Hunt
65594f972c Gateway: unify plugin interactive callback state (#50722)
Merged via squash.

Prepared head SHA: 7a2740b18a
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-19 22:09:38 -04:00
Shakker
61ae7e033b fix(ci): isolate remaining unit-fast OOM hotspots 2026-03-20 01:58:21 +00:00
Shakker
1fb30fbf78 fix(test): stub pnpm in pre-commit hook fixture 2026-03-20 01:58:21 +00:00
Vincent Koc
a2174f1ff1 fix(hooks): skip repo check outside workspace 2026-03-19 18:56:43 -07:00
Shakker
cf2a66b508 chore(docs): refresh generated config baseline 2026-03-20 01:52:27 +00:00
Vincent Koc
e009920256 fix(ci): isolate remaining stale OOM hotspots 2026-03-19 18:49:12 -07:00
Shakker
a19f058145 fix(test): mock zalouser runtime in outbound payload contract 2026-03-20 01:45:20 +00:00
Shakker
f91fad1710 fix(ci): isolate high-heap unit suites from unit-fast 2026-03-20 01:36:39 +00:00
Shakker
ac18a734ac fix(ci): cap top-level test lane concurrency 2026-03-20 01:36:12 +00:00
Shakker
55e12bd236 fix(plugins): stabilize bundle MCP path assertions 2026-03-20 01:11:58 +00:00
Shakker
c95d1c101b fix(cron): avoid async context token warmup in isolated runs 2026-03-20 01:11:58 +00:00
joshavant
6309b1da6c Gateway: preserve interactive pairing visibility on supersede 2026-03-19 19:57:45 -05:00
Gustavo Madeira Santana
a953cb5209 Matrix: fix runtime API duplicate exports 2026-03-19 20:53:35 -04:00
Vincent Koc
d518260bb8 fix(status): slim json startup path 2026-03-19 16:55:13 -07:00
Harold Hunt
41628770f5 Tests: trim command secret gateway imports (#50663)
Merged via squash.

Prepared head SHA: 7f64fd3ee1
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-19 19:53:02 -04:00
Vincent Koc
aa172f2169 fix(matrix): keep runtime api import-safe 2026-03-19 16:39:27 -07:00
Vincent Koc
c38295c7a2 test(ci): tighten startup memory thresholds 2026-03-19 16:28:00 -07:00
Vincent Koc
0f69b5c11a fix(status): keep startup paths free of plugin warmup 2026-03-19 16:26:58 -07:00
Josh Avant
8e132aed6e Hardening: refresh stale device pairing requests and pending metadata (#50695)
* Docs: clarify device pairing supersede behavior

* Device pairing: supersede pending requests on auth changes
2026-03-19 18:26:06 -05:00
Vincent Koc
9486f6e379 fix(build): suppress singleton smoke deprecation noise 2026-03-19 16:07:53 -07:00
Vincent Koc
f3971571fe fix(plugins): fail strict bootstrap on plugin load errors 2026-03-19 16:07:53 -07:00
Vincent Koc
009f494cd9 fix(plugin-sdk): stop library import warmup side effects 2026-03-19 16:07:53 -07:00
Tak Hoffman
192151610f fix(status): skip plugin compatibility scan on empty json path 2026-03-19 18:06:03 -05:00
Vincent Koc
20001a50c5 fix(build): suppress known-safe bottleneck eval warnings 2026-03-19 15:45:56 -07:00
Tak Hoffman
801e4bede6 Git: run pnpm check in pre-commit hook 2026-03-19 17:41:33 -05:00
Vincent Koc
bbfeb0b6f9 fix(ci): cache node in install smoke image 2026-03-19 15:38:16 -07:00
Vincent Koc
c3b05fc4d9 docs: add missing title, remove stale description fields from frontmatter 2026-03-19 15:26:26 -07:00
Vincent Koc
14eb49c18a test(feishu): fix lifecycle mock typing 2026-03-19 15:26:14 -07:00
Vincent Koc
d80b83e8e3 fix(plugins): scope sdk aliases to loaded module paths 2026-03-19 15:25:54 -07:00
Vincent Koc
a245916dcb fix(ci): repair test-parallel heap snapshot parsing 2026-03-19 15:25:29 -07:00
Vincent Koc
ac850e815b fix(ci): replace tlon git api dependency 2026-03-19 15:25:29 -07:00
Tak Hoffman
2884ac13b2 test: add Zalo pairing lifecycle regression 2026-03-19 17:13:38 -05:00
Josh Lehman
35bc00c55b test: reduce low-memory Vitest pressure (#50652)
* test: reduce low-memory Vitest pressure

Reuse the bundled config baseline inside doc-baseline tests, keep that hotspot out of the shared unit-fast lane, and make OPENCLAW_TEST_PROFILE=low default to process forks instead of vmForks.

* test: keep low-profile vmForks in CI

Scope the low-profile forks fallback to local runs so the existing CI contracts lane keeps its current pool behavior.
2026-03-19 15:02:48 -07:00
Harold Hunt
bbd62469fa Tests: Add tooling / skill for detecting and fixing memory leaks in tests (#50654)
* Tests: add periodic heap snapshot tooling

* Skills: add test heap leak workflow

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update scripts/test-parallel.mjs

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 14:59:13 -07:00
Tak Hoffman
da8fb70525 test: fix Feishu lifecycle type checks 2026-03-19 16:54:39 -05:00
Tak Hoffman
73e08775d7 test: add voice-call hangup-once lifecycle regression 2026-03-19 16:50:36 -05:00
Tak Hoffman
566e4cf77b test: add Zalo reply-once lifecycle regression 2026-03-19 16:50:36 -05:00
Vincent Koc
5841e3b493 fix(ci): split redact snapshot schema coverage 2026-03-19 14:49:01 -07:00
Vincent Koc
aeb2adf240 fix(ci): split redact snapshot restore coverage 2026-03-19 14:49:01 -07:00
Vincent Koc
38807fff20 fix(ci): split plugin sdk bundle coverage 2026-03-19 14:49:01 -07:00
Vincent Koc
ec2278192d fix(ci): reduce test runtime retention hotspots 2026-03-19 14:49:01 -07:00
Vincent Koc
d03c110a0a fix(ci): split secrets runtime integration coverage 2026-03-19 14:49:01 -07:00
Vincent Koc
a54d3dc679 test(feishu): fix bot-menu binding mock typing 2026-03-19 14:49:01 -07:00
Tak Hoffman
628b55a825 test: add Feishu ACP failure lifecycle regression 2026-03-19 16:33:04 -05:00
Tak Hoffman
c7cebd608b test: add Feishu broadcast lifecycle regression 2026-03-19 16:33:03 -05:00
Tak Hoffman
7d50e7fa85 test: add Feishu card-action lifecycle regression 2026-03-19 16:33:03 -05:00
Vincent Koc
3c806a9692 fix(ci): stabilize bundle hooks and mcp path seams 2026-03-19 14:26:52 -07:00
Vincent Koc
247a19a694 fix(hooks): bypass stale plugin bundle caches 2026-03-19 14:26:52 -07:00
Vincent Koc
83a267e2f3 fix(ci): reset deep test runtime state 2026-03-19 14:23:32 -07:00
Josh Lehman
ae02f40144 fix: load matrix legacy helper through native ESM when possible (#50623)
* fix(matrix): load legacy helper natively when possible

* fix(matrix): narrow jiti fallback to source helpers

* fix(matrix): fall back to jiti for source-style helper wrappers
2026-03-19 14:21:42 -07:00
Vincent Koc
8412498c2c docs: convert FAQ to Mintlify accordion format, fix TOC link, enrich help index 2026-03-19 14:18:39 -07:00
Tak Hoffman
0e825ece05 test: add Feishu bot-menu lifecycle regression 2026-03-19 16:16:46 -05:00
Vincent Koc
7f52a8a3a5 fix(ci): isolate top unit-fast OOM offenders 2026-03-19 14:15:52 -07:00
Josh Avant
1878272f67 CLI: prune inactive gateway auth credentials on mode set (#50639) 2026-03-19 16:05:43 -05:00
Tak Hoffman
ca757b6b77 test: add Feishu reply-once lifecycle regression 2026-03-19 16:04:53 -05:00
Vincent Koc
98298f7931 fix(ci): trace test runner memory retention 2026-03-19 14:02:19 -07:00
Vincent Koc
b7c39aa4d4 fix(ci): isolate config doc baseline heap pressure 2026-03-19 13:56:40 -07:00
Vincent Koc
f1be7d4cb3 fix(ci): isolate memory OOM hotspots from unit-fast 2026-03-19 13:44:35 -07:00
Vincent Koc
a94e21e0a7 docs(install): update container setup paths 2026-03-19 13:40:26 -07:00
Vincent Koc
46ccbacbd9 refactor(scripts): move container setup entrypoints 2026-03-19 13:40:26 -07:00
Vincent Koc
3b79494cbf fix(runtime): lazy-load setup shims and align contracts 2026-03-19 13:33:32 -07:00
Vincent Koc
7bbd01379e fix(deps): use https git sources for extension installs 2026-03-19 13:33:32 -07:00
Vincent Koc
ca74eb37da fix(extensions): repair matrix contracts and test boundaries 2026-03-19 13:33:32 -07:00
Vincent Koc
0aa4950d21 fix(core): restore session reset defaults and type seams 2026-03-19 13:33:32 -07:00
Vincent Koc
7bc7dd055a docs: sort Linux Server (vps) alphabetically in Hosting nav 2026-03-19 13:31:55 -07:00
Vincent Koc
3de8c3d053 docs: move Oracle, DigitalOcean, Raspberry Pi to Install > Hosting, rewrite with Steps 2026-03-19 13:29:39 -07:00
Vincent Koc
8dea2b124b docs: rename VPS to Linux Server, update provider links for moved pages 2026-03-19 13:29:39 -07:00
Vincent Koc
003ca0123d test(ci): trim embedding harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
36df0095c4 test(ci): trim memory dedupe harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
0fd3632d68 test(ci): trim memory atomic harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
22528af34d test(ci): trim gateway plugin harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
f60017d725 test(ci): trim memory cli harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
7a596b2305 test(ci): trim threading harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
60253111a3 test(ci): trim context isolation harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
962a8fea90 test(ci): trim thread lane harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
14e84cf0b3 test(ci): trim runtime test harness churn 2026-03-19 12:22:41 -07:00
Vincent Koc
9117836981 docs: deep rewrite Docker page (851→375 lines), trim sandbox duplication, add Steps 2026-03-19 12:07:42 -07:00
Vincent Koc
ebb6738e9d docs: improve VPS hub page and convert Podman to Mintlify Steps 2026-03-19 12:07:42 -07:00
Vincent Koc
34adde2e41 docs: rewrite ansible, bun, nix install pages with Mintlify Steps and improved readability 2026-03-19 12:07:42 -07:00
Vincent Koc
815d603ce2 chore: Delete infra directory 2026-03-19 12:05:32 -07:00
Vincent Koc
a6021cf78f docs: add Discord link to navbar 2026-03-19 11:58:25 -07:00
Vincent Koc
e466b55661 docs: convert Fly, Hetzner, GCP, Azure hosting pages to Mintlify Steps 2026-03-19 11:56:56 -07:00
Vincent Koc
7187d1da06 docs: rewrite updating.md (276→128 lines) and migrating.md (193→107 lines) for readability 2026-03-19 11:56:56 -07:00
Vincent Koc
517570d0fb docs: restructure Install nav — shorter group names, A-Z order, fix hosting titles, move dev channels to Maintenance 2026-03-19 11:56:56 -07:00
Tak Hoffman
66894db1b6 test: guard pi package graph alignment 2026-03-19 13:50:26 -05:00
Vincent Koc
3496ecc2ec chore: Delete changelog/fragments directory 2026-03-19 11:44:33 -07:00
Vincent Koc
e5b50ba0d5 docs: fix remaining install issues — stale versions, Docker TOC, ARM note, frontmatter 2026-03-19 11:42:57 -07:00
Vincent Koc
30ddeabfdc docs: fix install section — broken anchors, wrong commands, json5 fences, add next-steps sections 2026-03-19 11:38:51 -07:00
Vincent Koc
071319545f docs: deduplicate chat tokens across hosting pages, remove Nix packaging note 2026-03-19 11:37:47 -07:00
Vincent Koc
e1a39c6ba5 docs: rewrite install index for readability — flat structure, clearer hierarchy, better hosting cards 2026-03-19 11:30:48 -07:00
Vincent Koc
22c1bda2a0 docs: clarify native Windows support alongside WSL2 across getting-started, windows, and onboarding-overview 2026-03-19 11:28:53 -07:00
Vincent Koc
cb78f38da9 docs: clarify subscription auth and custom provider examples in features 2026-03-19 11:26:07 -07:00
Vincent Koc
e121aad2c1 docs: improve Get Started readability — rewrite getting-started, onboarding-overview, features, and openclaw pages 2026-03-19 11:24:30 -07:00
Vincent Koc
392047b49f docs: collapse Get Started tab into 3 groups (Option C) 2026-03-19 11:10:56 -07:00
Vincent Koc
6b9ebffebb test(ci): trim command secret gateway harness churn 2026-03-19 11:08:33 -07:00
Vincent Koc
feb9a3b5b2 fix(ci): harden test gating under load 2026-03-19 11:08:33 -07:00
Vincent Koc
51519b4086 fix(ci): fail on fatal test runner output 2026-03-19 11:08:33 -07:00
Vincent Koc
0a8885d6c1 fix(ci): restore hook and guardrail tests 2026-03-19 11:08:32 -07:00
Vincent Koc
cb552bcc42 docs: fix duplicate redirect source, fix faq heading dash-vs-comma for valid anchor 2026-03-19 11:05:10 -07:00
Vincent Koc
d9e9a9e819 fix(pi): align package graph and declare compaction summaries 2026-03-19 11:02:18 -07:00
Vincent Koc
13be4b4cc2 docs: add Groq provider page 2026-03-19 10:57:59 -07:00
Vincent Koc
b28cf6a8a4 docs: split memory.md into concept intro + reference page 2026-03-19 10:57:47 -07:00
Vincent Koc
d57c327d45 docs: sub-group CLI reference into 8 clusters 2026-03-19 10:57:34 -07:00
Vincent Koc
089c8bc65e docs: Phase 3 IA restructure — move pi to Reference, merge Models groups, move install/node to Install, move prose to Skills, migrate brave-search/perplexity/tts into tools/ 2026-03-19 10:42:46 -07:00
Vincent Koc
faf81c5574 docs: clarify Pi agent core relationship in runtime boundaries 2026-03-19 10:35:09 -07:00
Vincent Koc
a18f7d7d35 docs: add orphan pages to nav, fix Twitch URL, normalize json5 fences, fix msteams config 2026-03-19 10:33:03 -07:00
Vincent Koc
9f2a01d972 docs: replace stale claude-sonnet-4-5 with 4-6, normalize Node version, remove stale dates 2026-03-19 10:33:03 -07:00
Vincent Koc
0b11ee48f8 docs: fix 26 broken anchor links across 18 files 2026-03-19 10:33:02 -07:00
Vincent Koc
624d536551 docs: remove quickstart stub from hubs, add redirect to getting-started 2026-03-19 10:32:30 -07:00
Vincent Koc
1dd857f6a6 docs: add API key prereq, first-message step, fix landing page quick start 2026-03-19 10:32:30 -07:00
Vincent Koc
65a2917c8f docs: remove pi-mono jargon, fix features list, update Perplexity config path 2026-03-19 10:32:30 -07:00
fuller-stack-dev
36f394c299 fix(gateway): increase WS handshake timeout from 3s to 10s (#49262)
* fix(gateway): increase WS handshake timeout from 3s to 10s

The 3-second default is too aggressive when the event loop is under load
(concurrent sessions, compaction, agent turns), causing spurious
'gateway closed (1000)' errors on CLI commands like `openclaw cron list`.

Changes:
- Increase DEFAULT_HANDSHAKE_TIMEOUT_MS from 3_000 to 10_000
- Add OPENCLAW_HANDSHAKE_TIMEOUT_MS env var for user override (no VITEST gate)
- Keep OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS as fallback for existing tests

Fixes #46892

* fix: restore VITEST guard on test env var, use || for empty-string fallback, fix formatting

* fix: cover gateway handshake timeout env override (#49262) (thanks @fuller-stack-dev)

---------

Co-authored-by: Wilfred <wilfred@Wilfreds-Mac-mini.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-19 22:46:40 +05:30
Vincent Koc
3dfd8eef7f ci(node22): drop duplicate config docs check from compat lane 2026-03-19 09:56:42 -07:00
Harold Hunt
401ffb59f5 CLI: support versioned plugin updates (#49998)
Merged via squash.

Prepared head SHA: 545ea60fa2
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-19 12:51:10 -04:00
Vincent Koc
7fb142d115 test(whatsapp): override config-runtime mock exports safely 2026-03-19 09:42:13 -07:00
Vincent Koc
639f78d257 style(format): restore import order drift 2026-03-19 09:38:42 -07:00
Vincent Koc
dcbcecfb85 fix(ci): resolve Claude marketplace shortcuts from OS home 2026-03-19 09:38:42 -07:00
Ayaan Zaidi
f1e4f8e8d2 fix: add changelog attribution for Azure Foundry custom providers (#50535) 2026-03-19 22:07:19 +05:30
Ayaan Zaidi
91104ac740 fix(onboard): respect services.ai custom provider compatibility 2026-03-19 22:07:19 +05:30
Ayaan Zaidi
5b1836d700 fix(onboard): raise azure probe output floor 2026-03-19 21:53:27 +05:30
Ayaan Zaidi
7a57082466 fix(provider): onboard azure custom endpoints via responses 2026-03-19 21:53:27 +05:30
Vincent Koc
9d772d6eab fix(ci): normalize bundle mcp paths and skip explicit channel scans 2026-03-19 09:16:45 -07:00
Gustavo Madeira Santana
ff6541f69d Matrix: fix Jiti runtime API boundary 2026-03-19 11:40:44 -04:00
Tak Hoffman
5a41229a6d docs: simplify AGENTS validation policy 2026-03-19 10:34:04 -05:00
Tak Hoffman
e1b5ffadca docs: clarify scoped-test validation policy 2026-03-19 10:29:39 -05:00
Tak Hoffman
fb18034011 test: add macmini test profile 2026-03-19 10:29:39 -05:00
xubaolin
bfe979dd5b refactor: add Android LocationHandler test seam (#50027) (thanks @xu-baolin) 2026-03-19 20:57:43 +05:30
Gustavo Madeira Santana
12ad809e79 Matrix: fix runtime encryption loading 2026-03-19 11:08:17 -04:00
Gustavo Madeira Santana
8268c28053 Matrix: isolate thread binding manager stateDir reuse 2026-03-19 11:08:16 -04:00
Vincent Koc
44cd4fb55f fix(ci): repair main type and boundary regressions 2026-03-19 08:00:33 -07:00
Gustavo Madeira Santana
0c4fdf1284 Format: apply import ordering cleanup 2026-03-19 10:33:16 -04:00
Gustavo Madeira Santana
f4f0b171d3 Matrix: isolate credential write runtime 2026-03-19 10:33:16 -04:00
Liu Ricardo
8c01347989 test(contracts): cover matrix session binding adapters (#50369)
Merged via squash.

Prepared head SHA: 25412dbc2c
Co-authored-by: ChroniCat <220139611+ChroniCat@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-19 10:26:37 -04:00
Gustavo Madeira Santana
c7cbc8cc0b CI: validate plugin runtime deps in install smoke 2026-03-19 09:44:27 -04:00
Vincent Koc
79d7fdce93 test(telegram): inject media loader in delivery replies 2026-03-19 06:30:59 -07:00
Vincent Koc
a0445b192e test(signal): mock daemon readiness in monitor suite 2026-03-19 06:30:59 -07:00
Vincent Koc
1c1a3b6a75 fix(discord): break plugin-sdk account helper cycle 2026-03-19 06:30:59 -07:00
Gustavo Madeira Santana
dd10f290e8 Matrix: wire thread binding command support 2026-03-19 09:24:31 -04:00
Johnson Shi
191e1947c1 docs: add Azure VM deployment guide with in-repo ARM templates and bootstrap script (#47898)
* docs: add Azure Linux VM install guide

* docs: move Azure guide into dedicated docs/install/azure layout

* docs: polish Azure guide onboarding and reference links

* docs: address Azure review feedback on bootstrap safety

* docs: format azure ARM template

* docs: flatten Azure install docs and move ARM assets
2026-03-19 08:15:06 -05:00
Harold Hunt
5508374669 fix(plugins): share split-load singleton state (openclaw#50418) thanks @huntharo
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
2026-03-19 09:10:24 -04:00
Gustavo Madeira Santana
7f86be1037 Matrix: accept messageId alias for poll votes 2026-03-19 08:50:49 -04:00
Tyler Yust
20728e1035 fix: stop newline block streaming from sending per paragraph 2026-03-19 05:40:12 -07:00
Tyler Yust
47b02435c1 fix: honor BlueBubbles chunk mode and envelope timezone 2026-03-19 05:40:12 -07:00
Gustavo Madeira Santana
75e6c8fe9c Matrix: persist clean shutdown sync state 2026-03-19 08:31:44 -04:00
Gustavo Madeira Santana
16129272dc Tests: update Matrix agent bind fixtures 2026-03-19 08:31:38 -04:00
Gustavo Madeira Santana
f8eb23de1c CLI: fix check failures 2026-03-19 08:29:57 -04:00
Gustavo Madeira Santana
34ee75b174 Matrix: restore doctor migration previews 2026-03-19 08:09:52 -04:00
Gustavo Madeira Santana
4443cc771a Matrix: wire startup migration into doctor and gateway 2026-03-19 08:03:57 -04:00
Gustavo Madeira Santana
f69450b170 Matrix: fix typecheck and boundary drift 2026-03-19 08:03:56 -04:00
Nimrod Gutman
c4a4050ce4 fix(macos): align exec command parity (#50386)
* fix(macos): align exec command parity

* fix(macos): address exec review follow-ups
2026-03-19 13:51:17 +02:00
Vincent Koc
009a10bce2 fix(ci): avoid ssh-only git dependency fetches 2026-03-19 01:57:34 -07:00
Vincent Koc
c37a92ca6e fix(cli): clarify source archive install failures 2026-03-19 01:49:28 -07:00
Ayaan Zaidi
040c43ae21 feat(android): benchmark script 2026-03-19 13:13:14 +05:30
Peter Steinberger
f3097b4c09 refactor: install optional channels for remove 2026-03-19 07:20:55 +00:00
Ayaan Zaidi
0443ee82be fix(android): auto-connect gateway on app open 2026-03-19 12:49:18 +05:30
Peter Steinberger
22943f24a9 refactor: prune bundled sdk facades 2026-03-19 07:17:04 +00:00
Shaun Tsai
bcc725ffe2 fix(agents): strip prompt cache for non-OpenAI responses endpoints (#49877) thanks @ShaunTsai
Fixes #48155

Co-authored-by: Shaun Tsai <13811075+ShaunTsai@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
2026-03-19 15:12:29 +08:00
Josh Avant
b965ef3802 Channels: stabilize lane harness and monitor tests (#50167)
* Channels: stabilize lane harness regressions

* Signal tests: stabilize tool-result harness dispatch

* Telegram tests: harden polling restart assertions

* Discord tests: stabilize channel lane harness coverage

* Slack tests: align slash harness runtime mocks

* Telegram tests: harden dispatch and pairing scenarios

* Telegram tests: fix SessionEntry typing in bot callback override case

* Slack tests: avoid slash runtime mock deadlock

* Tests: address bot review follow-ups

* Discord: restore accounts runtime-api seam

* Tests: stabilize Discord and Telegram channel harness assertions

* Tests: clarify Discord mock seam and remove unused Telegram import

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-19 01:47:48 -05:00
Gustavo Madeira Santana
ddd921ff0b Docs: add new Matrix plugin changelog entry 2026-03-19 02:21:34 -04:00
Gustavo Madeira Santana
c5c2416ec2 Matrix: restore local sdk barrel imports 2026-03-19 02:03:17 -04:00
Gustavo Madeira Santana
94693f7ff0 Matrix: rebuild plugin migration branch 2026-03-19 01:58:29 -04:00
Gustavo Madeira Santana
513b4869d8 Discord: stabilize provider registry coverage 2026-03-19 01:53:55 -04:00
Ayaan Zaidi
1d3e596021 fix(pairing): include shared auth in setup codes 2026-03-19 11:20:31 +05:30
Ayaan Zaidi
608b9a9af2 fix(android): show copyable gateway diagnostics 2026-03-19 10:47:12 +05:30
Gustavo Madeira Santana
a2fa799a5c Tests: stabilize poll fallback coverage 2026-03-19 01:15:03 -04:00
Gustavo Madeira Santana
03f18ec043 Outbound: remove channel-specific message action fallbacks 2026-03-19 01:08:23 -04:00
Gustavo Madeira Santana
eaee01042b Plugin SDK: move generic message tool schemas out of core 2026-03-19 01:08:23 -04:00
Gustavo Madeira Santana
b48194a07e Plugins: move message tool schemas into channel plugins 2026-03-19 01:08:23 -04:00
Gustavo Madeira Santana
8467fb6601 Outbound: move target display fallbacks behind plugins 2026-03-19 01:08:22 -04:00
Ayaan Zaidi
d978ace90b fix: isolate CLI startup imports (#50212)
* fix: isolate CLI startup imports

* fix: clarify CLI preflight behavior

* fix: tighten main-module detection

* fix: isolate CLI startup imports (#50212)
2026-03-19 10:34:29 +05:30
Josh Avant
68bc6effc0 Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155)
* Telegram: stabilize Area 2 DM and model callbacks

* Telegram: fix dispatch test deps wiring

* Telegram: stabilize area2 test harness and gate flaky sticker e2e

* Telegram: address review feedback on config reload and tests

* Telegram tests: use plugin-sdk reply dispatcher import

* Telegram tests: add routing reload regression and track sticker skips

* Telegram: add polling-session backoff regression test

* Telegram tests: mock loadWebMedia through plugin-sdk path

* Telegram: refresh native and callback routing config

* Telegram tests: fix compact callback config typing
2026-03-19 00:01:14 -05:00
Tak Hoffman
53a34c39f6 Fix windows ACL os mock typing 2026-03-18 23:49:53 -05:00
Tak Hoffman
3261a2a0b1 Tighten bug report grounding guidance 2026-03-18 23:46:45 -05:00
Tak Hoffman
74b9ad010a test: preserve node os exports in windows acl mock 2026-03-18 23:38:25 -05:00
Josh Avant
a2a9a553e1 Stabilize plugin loader and Docker extension smoke (#50058)
* Plugins: stabilize Area 6 loader and Docker smoke

* Docker: fail fast on extension npm install errors

* Tests: stabilize loader non-native Jiti boundary CI timeout

* Tests: stabilize plugin loader Jiti source-runtime coverage

* Docker: keep extension deps on lockfile graph

* Tests: cover tsx-cache renamed package cwd fallback

* Tests: stabilize plugin-sdk export subpath assertions

* Plugins: align tsx-cache alias fallback with subpath fallback

* Tests: normalize guardrail path checks for Windows

* Plugins: restrict plugin-sdk cwd fallback to trusted roots

* Tests: exempt outbound-session from extension import guard

* Tests: tighten guardrails and cli-entry trust coverage

* Tests: guard optional loader fixture exports

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 23:35:32 -05:00
Tak Hoffman
3abffe0967 fix: stabilize windows temp and path handling 2026-03-18 23:29:14 -05:00
Gustavo Madeira Santana
afa95fade0 Tests: align fixtures with current gateway and model types 2026-03-19 00:25:24 -04:00
Gustavo Madeira Santana
83d284610c Diffs: route plugin context through artifacts 2026-03-19 00:24:00 -04:00
Tak Hoffman
a98ffa41d0 build: make whatsapp plugin publishable 2026-03-18 23:22:44 -05:00
Tak Hoffman
16567ba4e7 test: align whatsapp expectations with current contracts 2026-03-18 23:17:48 -05:00
Tak Hoffman
b8b1e2cf50 AGENTS.md: split GHSA advisory workflow into its own skill 2026-03-18 23:11:18 -05:00
Tak Hoffman
f6c57edd5c Tests: tighten channel import guardrails 2026-03-18 23:08:02 -05:00
Tak Hoffman
79e13e0a5e AGENTS.md: forbid merge commits on main 2026-03-18 23:01:22 -05:00
Tak Hoffman
5b7b5529f1 Plugins: remove shared extension boundary debt 2026-03-18 22:58:40 -05:00
Tak Hoffman
126839380c Tests: fix current check failures 2026-03-18 22:58:40 -05:00
Tak Hoffman
74756b91b7 AGENTS.md: block test-baseline silencing edits 2026-03-18 22:55:27 -05:00
Tak Hoffman
f7675eca6b AGENTS.md: split local and safety notes 2026-03-18 22:55:27 -05:00
Tak Hoffman
59269f3534 AGENTS.md: extract repo workflows into skills 2026-03-18 22:55:27 -05:00
Peter Steinberger
25015161fe refactor: install optional channel capabilities on demand 2026-03-19 03:39:15 +00:00
Peter Steinberger
19126033dd build: regenerate protocol swift models 2026-03-19 03:38:35 +00:00
Peter Steinberger
b7ca56f662 refactor: install heavy plugins on demand 2026-03-19 03:37:30 +00:00
Peter Steinberger
83c5bc946d fix: restore full gate stability 2026-03-19 03:36:03 +00:00
lixuankai
c86de678f3 feat(android): support android node sms.search (#48299)
* feat(android): support android node sms.search

* feat(android): support android node sms.search

* fix(android): split sms search permissions

* fix: document android sms.search landing (#48299) (thanks @lixuankai)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-19 08:52:15 +05:30
Peter Steinberger
58cf9b865f refactor: route extension seams through public apis 2026-03-19 03:20:10 +00:00
Tak Hoffman
8404f56841 Docs: trialing stronger AGENTS.md rules 2026-03-18 22:18:52 -05:00
Peter Steinberger
30a94dfd3b refactor: untangle whatsapp runtime boundary 2026-03-19 03:13:48 +00:00
Peter Steinberger
510f4276b5 refactor: tighten sdk reply pipeline contract 2026-03-19 03:13:15 +00:00
clay-datacurve
7b61ca1b06 Session management improvements and dashboard API (#50101)
* fix: make cleanup "keep" persist subagent sessions indefinitely

* feat: expose subagent session metadata in sessions list

* fix: include status and timing in sessions_list tool

* fix: hide injected timestamp prefixes in chat ui

* feat: push session list updates over websocket

* feat: expose child subagent sessions in subagents list

* feat: add admin http endpoint to kill sessions

* Emit session.message websocket events for transcript updates

* Estimate session costs in sessions list

* Add direct session history HTTP and SSE endpoints

* Harden dashboard session events and history APIs

* Add session lifecycle gateway methods

* Add dashboard session API improvements

* Add dashboard session model and parent linkage support

* fix: tighten dashboard session API metadata

* Fix dashboard session cost metadata

* Persist accumulated session cost

* fix: stop followup queue drain cfg crash

* Fix dashboard session create and model metadata

* fix: stop guessing session model costs

* Gateway: cache OpenRouter pricing for configured models

* Gateway: add timeout session status

* Fix subagent spawn test config loading

* Gateway: preserve operator scopes without device identity

* Emit user message transcript events and deduplicate plugin warnings

* feat: emit sessions.changed lifecycle event on subagent spawn

Adds a session-lifecycle-events module (similar to transcript-events)
that emits create events when subagents are spawned. The gateway
server.impl.ts listens for these events and broadcasts sessions.changed
with reason=create to SSE subscribers, so dashboards can pick up new
subagent sessions without polling.

* Gateway: allow persistent dashboard orchestrator sessions

* fix: preserve operator scopes for token-authenticated backend clients

Backend clients (like agent-dashboard) that authenticate with a valid gateway
token but don't present a device identity were getting their scopes stripped.
The scope-clearing logic ran before checking the device identity decision,
so even when evaluateMissingDeviceIdentity returned 'allow' (because
roleCanSkipDeviceIdentity passed for token-authed operators), scopes were
already cleared.

Fix: also check decision.kind before clearing scopes, so token-authenticated
operators keep their requested scopes.

* Gateway: allow operator-token session kills

* Fix stale active subagent status after follow-up runs

* Fix dashboard image attachments in sessions send

* Fix completed session follow-up status updates

* feat: stream session tool events to operator UIs

* Add sessions.steer gateway coverage

* Persist subagent timing in session store

* Fix subagent session transcript event keys

* Fix active subagent session status in gateway

* bump session label max to 512

* Fix gateway send session reactivation

* fix: publish terminal session lifecycle state

* feat: change default session reset to effectively never

- Change DEFAULT_RESET_MODE from "daily" to "idle"
- Change DEFAULT_IDLE_MINUTES from 60 to 0 (0 = disabled/never)
- Allow idleMinutes=0 through normalization (don't clamp to 1)
- Treat idleMinutes=0 as "no idle expiry" in evaluateSessionFreshness
- Default behavior: mode "idle" + idleMinutes 0 = sessions never auto-reset
- Update test assertion for new default mode

* fix: prep session management followups (#50101) (thanks @clay-datacurve)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-03-19 12:12:30 +09:00
Tak Hoffman
a837ebdd67 Docs: update AGENTS.md import boundaries 2026-03-18 22:06:44 -05:00
Tyler Yust
a290f5e50f fix: persist outbound sends and skip stale cron deliveries (#50092)
* fix(bluebubbles): auto-create chats for new numbers, persist outbound messages to session transcripts

Two fixes for BlueBubbles message tool behavior:

1. **Attachment sends to new phone numbers**: sendBlueBubblesAttachment now
   auto-creates a new DM chat (via /api/v1/chat/new) when no existing chat
   is found for a handle target, matching the behavior already present in
   sendMessageBlueBubbles for text sends. The existing createNewChatWithMessage
   is refactored into a reusable createChatForHandle that returns the chatGuid.

2. **Outbound message session persistence**: Ensures outbound messages sent
   via the message tool are reliably tracked in session transcripts:
   - ensureOutboundSessionEntry now falls back to directly creating a session
     store entry when recordSessionMetaFromInbound returns null, guaranteeing
     a sessionId exists for the subsequent mirror append.
   - appendAssistantMessageToSessionTranscript now normalizes the session key
     (lowercased) when looking up the store, preventing case mismatches
     between the store keys and the mirror sessionKey.

Tests added for all changes.

* test(slack): verify outbound session tracking and new target sends for Slack

The shared infrastructure changes from the BlueBubbles fix (session key
normalization in transcript.ts and fallback session entry creation in
outbound-session.ts) already cover Slack. Slack's sendMessageSlack uses
conversations.open to auto-create DM channels for new user targets.

Add tests confirming:
- Slack user DM and channel session route resolution (outbound.test.ts)
- Slack session key normalization for transcript append (sessions.test.ts)
- Slack outbound sendText/sendMedia to new user and channel targets (channel.test.ts)

* fix(cron): skip stale delayed deliveries

* fix: prep PR #50092
2026-03-19 11:40:34 +09:00
Tyler Yust
ffc1d5459c fix: resolve failing tests on main (warning filter + slack mocks) 2026-03-18 19:31:12 -07:00
clawdia
6ae68faf5f fix(whatsapp): use globalThis singleton for active-listener Map (#47433)
Merged via squash.

Prepared head SHA: 1c43dbff39
Co-authored-by: clawdia67 <261743618+clawdia67@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-18 22:16:31 -03:00
Josh Avant
0f0cecd2e8 Discord: enforce strict DM component allowlist auth (#49997)
* Discord: enforce strict DM component allowlist auth

* Discord: align model picker fallback routing

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 20:11:47 -05:00
Peter Steinberger
7b151afeeb test: align plugin-sdk subpath guardrail with current exports (#49249) 2026-03-18 18:02:44 -07:00
Peter Steinberger
371b3d22f5 fix: export imessage-core plugin-sdk subpath (#49249) 2026-03-18 18:02:44 -07:00
Peter Steinberger
42b9212eb2 fix: preserve interactive Ollama model selection (#49249) (thanks @BruceMacD) 2026-03-18 18:02:44 -07:00
Bruce MacDonald
f8c70bf1f1 fix(ollama): don't auto-pull glm-4.7-flash during Local mode onboarding 2026-03-18 18:02:44 -07:00
Vincent Koc
de86e25fd4 fix(ci): skip extension lanes with no tests 2026-03-18 17:52:28 -07:00
Vincent Koc
8884643f40 fix(plugin-sdk): restore imessage-core export 2026-03-18 17:49:51 -07:00
Peter Steinberger
002cc07322 refactor: tighten plugin sdk channel surfaces 2026-03-19 00:46:36 +00:00
Vincent Koc
f19cb738af fix(plugin-sdk): restore public runtime subpaths 2026-03-18 17:38:49 -07:00
Peter Steinberger
4cc0bb07c1 refactor: unify plugin sdk pairing flows 2026-03-19 00:31:03 +00:00
Vincent Koc
b736a92e19 fix(ci): gate extension relative package escapes 2026-03-18 17:27:57 -07:00
Peter Steinberger
c70837f07d refactor: converge plugin sdk channel helpers 2026-03-19 00:25:19 +00:00
Peter Steinberger
62b7b350c9 refactor: move bundled channel deps to plugin packages 2026-03-19 00:24:44 +00:00
Vincent Koc
9a9db87952 fix(release): isolate config doc surfaces and sdk exports 2026-03-18 17:14:15 -07:00
Peter Steinberger
60a55c9cbe fix(committer): accept argv and shell path blobs 2026-03-19 00:10:25 +00:00
Peter Steinberger
d7018aaf19 refactor: move bundled extension deps to plugin packages 2026-03-19 00:04:50 +00:00
Peter Steinberger
07d9f725b6 refactor: unify plugin sdk primitives 2026-03-18 23:58:56 +00:00
Vincent Koc
bea90b72e6 docs: update development-channels with --tag, --dry-run, status, and main warning 2026-03-18 16:41:43 -07:00
Vincent Koc
5f97645382 docs: update development-channels with --tag, --dry-run, and status sections 2026-03-18 16:41:43 -07:00
Peter Steinberger
46f49eb6eb refactor: shrink plugin sdk public surface 2026-03-18 23:31:08 +00:00
Peter Steinberger
6e044ace28 fix: keep bundled runtime deps out of release pack 2026-03-18 23:18:36 +00:00
Peter Steinberger
b9c4db1a77 test: fix stale boundary guardrails 2026-03-18 23:09:59 +00:00
Vincent Koc
a996f60f11 fix(release): isolate config docs child env 2026-03-18 16:05:40 -07:00
Vincent Koc
757c2cc2de fix(release): isolate bundled config docs loading 2026-03-18 16:01:43 -07:00
Vincent Koc
7d8d3d9d77 docs: merge duplicate OpenRouter entry, fix broken plugin anchor links 2026-03-18 16:00:46 -07:00
Vincent Koc
67da67b61a docs: fix tools nav A-Z, split plugin page, consolidate sandbox docs, add OpenShell page (#50055)
* docs: fix A-Z built-in tools nav, split plugin page, consolidate sandbox docs

* docs: add dedicated OpenShell sandbox backend page

* style: format markdown tables

* docs: trim plugin page, restructure available plugins into table + categories
2026-03-18 15:44:08 -07:00
Josh Avant
2661de384f Matrix: make onboarding status runtime-safe (#49995)
* Matrix: make onboarding status runtime-safe

* Matrix tests: mock reply dispatch in BodyForAgent coverage

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 17:33:42 -05:00
Josh Avant
859889aae9 WhatsApp: stabilize inbound monitor and setup tests (#50007) 2026-03-18 17:08:57 -05:00
Vincent Koc
91d37ccfc3 fix(auth): lazy-load provider oauth helpers 2026-03-18 13:40:28 -07:00
Vincent Koc
6ebcd853be fix(plugin-sdk): isolate provider entry surfaces 2026-03-18 13:20:46 -07:00
Vincent Koc
b526098eb2 docs: restore original Credits heading, disambiguate H1 2026-03-18 12:38:46 -07:00
Vincent Koc
c749957c93 docs: fix duplicate Credits heading in credits.md 2026-03-18 12:34:37 -07:00
Vincent Koc
e5a1185796 docs: add extensions section to docs hubs 2026-03-18 12:29:02 -07:00
Vincent Koc
be3f4a7966 docs: add Building Extensions guide and nav entry 2026-03-18 12:28:19 -07:00
Vincent Koc
198de10523 docs: add missing H1 headings and fix HEARTBEAT template 2026-03-18 12:27:07 -07:00
Vincent Koc
63e09f8267 chore(changelog): remove fragment workflow drift 2026-03-18 12:26:56 -07:00
Vincent Koc
2797ae1583 docs: add missing voice-call CLI commands and contract test section to testing 2026-03-18 12:26:18 -07:00
Vincent Koc
cc5bd57bd7 docs: add missing provider pages (google, modelstudio, perplexity, volcengine) and nav entries 2026-03-18 12:26:01 -07:00
Vincent Koc
e9903c9133 Tests: align unit sharding with unit config 2026-03-18 12:16:07 -07:00
Josh Avant
e6911f0448 Tests: restore deterministic plugins CLI coverage (#49955)
* Tests: restore deterministic plugins CLI coverage

* CLI: preserve plugins exit control-flow narrowing

* Tests: fix plugins CLI mock typing for tsgo

* Tests: fix provider usage mock typing in key normalization
2026-03-18 14:05:04 -05:00
Vincent Koc
ef1346e503 Plugin SDK: route reply payload through public subpath 2026-03-18 12:01:15 -07:00
Vincent Koc
ecfa79ee4c Tests: fix provider auth plugin mock spread 2026-03-18 12:01:05 -07:00
Tak Hoffman
600f57c979 test: add architecture smell detector 2026-03-18 13:28:13 -05:00
darkamenosa
4b5487ee85 LINE: avoid runtime lookup during onboarding (#49960) 2026-03-19 01:27:21 +07:00
Onur
8f0727d75c Delete CNAME 2026-03-18 19:22:17 +01:00
Peter Steinberger
1746e130f9 test: fix imessage extension CI mocks 2026-03-18 18:20:04 +00:00
Peter Steinberger
a0d3dc94d0 perf: reduce unit test hot path overhead 2026-03-18 18:19:40 +00:00
Vincent Koc
fa52d122c4 Plugin SDK: route provider metadata through public models subpath 2026-03-18 11:18:04 -07:00
Peter Steinberger
62edfdffbd refactor: deduplicate reply payload handling 2026-03-18 18:14:57 +00:00
Vincent Koc
152d179302 Plugin SDK: add public WhatsApp runtime subpaths 2026-03-18 11:13:19 -07:00
Vincent Koc
8240fd900a Plugin SDK: route core channel runtimes through public subpaths 2026-03-18 11:00:58 -07:00
Josh Lehman
505d140aeb fix: stabilize build dependency resolution (#49928)
* build: mirror uuid for msteams

Add uuid to both the msteams bundled extension and the root package so the workspace build can resolve @microsoft/agents-hosting during tsdown while standalone extension installs also have the runtime dependency available.

Regeneration-Prompt: |
  pnpm build failed because @microsoft/agents-hosting 1.3.1 requires uuid in its published JS but does not declare it in its package manifest. The msteams extension dynamically imports that package, and the workspace build resolves it from the root dependency graph. Mirror uuid into the root package for workspace builds and keep it in extensions/msteams/package.json so standalone plugin installs also resolve it. Update the lockfile to match the manifest changes.

* build: prune stale plugin dist symlinks

Remove stale dist and dist-runtime plugin node_modules symlinks before tsdown runs. These links point back into extension installs, and tsdown's clean step can traverse them on rebuilds and hollow out the active pnpm dependency tree before plugin-sdk declaration generation runs.

Regeneration-Prompt: |
  pnpm build was intermittently failing in the plugin-sdk:dts phase after earlier build steps had already run. The symptom looked like missing root packages such as zod, ajv, commander, and undici even though a fresh install briefly fixed the problem. Investigate the build pipeline step by step rather than patching TypeScript errors. Confirm whether rebuilds mutate node_modules, identify the first step that does it, and preserve existing runtime-postbuild behavior.
  The key constraint is that dist and dist-runtime plugin node_modules links are intentional for runtime packaging, so do not remove that feature globally. Instead, make rebuilds safe by deleting only stale symlinks left in generated output before invoking tsdown, so tsdown cleanup cannot recurse back into the live pnpm install tree. Verify with repeated pnpm build runs.
2026-03-18 10:55:25 -07:00
Vincent Koc
ea74123ab2 Slack: fix directory test runtime stub 2026-03-18 10:54:00 -07:00
Vincent Koc
7d08070dd7 Plugins: generate bundled auth env metadata 2026-03-18 10:53:48 -07:00
Peter Steinberger
8d73bc77fa refactor: deduplicate reply payload helpers 2026-03-18 17:30:25 +00:00
scoootscooob
656679e6e0 Slack: remove duplicate directory imports (#49935) 2026-03-18 10:28:59 -07:00
scoootscooob
b49946a67e Slack: import directory helpers (#49930)
import the config-backed Slack directory helpers into the Slack channel plugin so directory.listPeers and directory.listGroups no longer throw at runtime, and add a regression test covering configured DM peer listing
2026-03-18 10:24:17 -07:00
Vincent Koc
ff326e90c3 Build: use hoisted pnpm linker 2026-03-18 10:14:53 -07:00
Vincent Koc
467ec4d5f3 Types: fix optional cluster check follow-ups 2026-03-18 10:02:40 -07:00
Peter Steinberger
05b1cdec3c test: make runner scheduling timing-driven 2026-03-18 16:57:38 +00:00
Vincent Koc
891e2a3da8 Build: isolate optional bundled plugin-sdk clusters 2026-03-18 09:54:22 -07:00
Vincent Koc
b4f16bad32 Plugin SDK: export windows spawn and temp path 2026-03-18 09:46:24 -07:00
Vincent Koc
a02bfd30c5 Plugin SDK: use public utility subpaths 2026-03-18 09:43:46 -07:00
Vincent Koc
f187e8bac4 Plugin SDK: use public slack subpath 2026-03-18 09:40:57 -07:00
Vincent Koc
e64cc1983f Plugin SDK: use public discord subpath 2026-03-18 09:39:12 -07:00
Vincent Koc
b3ca855283 Plugin SDK: use public whatsapp subpath 2026-03-18 09:37:54 -07:00
Peter Steinberger
27f655ed11 refactor: deduplicate channel runtime helpers 2026-03-18 16:37:27 +00:00
Vincent Koc
3e02635df3 Plugin SDK: use public telegram subpath 2026-03-18 09:33:21 -07:00
Vincent Koc
382640e674 Channels: trim optional bundled plugin defaults 2026-03-18 09:30:54 -07:00
Vincent Koc
d8008a9a67 Tools: classify optional bundled clusters 2026-03-18 09:26:39 -07:00
Peter Steinberger
3d8afb96bd fix: use transpiled jiti for source plugin shims 2026-03-18 16:24:45 +00:00
liyuan97
b64f4e313d MiniMax: add M2.7 models and update default to M2.7 (#49691)
* MiniMax: add M2.7 models and update default to M2.7

- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to provider catalog and model definitions
- Update default model from MiniMax-M2.5 to MiniMax-M2.7 across onboard, portal, and provider configs
- Update isModernMiniMaxModel to recognize M2.7 prefix
- Update all test fixtures to reflect M2.7 as default

Made-with: Cursor

* MiniMax: add extension test for model definitions

* update 2.7

* feat: add MiniMax M2.7 models and update default (#49691) (thanks @liyuan97)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-18 09:24:37 -07:00
Chris Kimpton
823a09acbe docs: clarify that CI test-fix-only PRs are handled by maintainers (#49679)
Co-authored-by: Shadow <shadow@openclaw.ai>
2026-03-18 11:21:46 -05:00
Peter Steinberger
10dc4d65d1 test: refresh plugin extension boundary baseline 2026-03-18 16:16:31 +00:00
Peter Steinberger
5fd482d6b0 test: align acp session mode list 2026-03-18 16:14:14 +00:00
Vincent Koc
73539ac787 Core: move web media seam out of plugin sdk 2026-03-18 09:12:23 -07:00
Vincent Koc
947dac48f2 Tests: cap shards for explicit file lanes 2026-03-18 08:59:37 -07:00
Vincent Koc
cfdc0fdbe1 Plugins: include fal in image-generation contract registry 2026-03-18 08:59:00 -07:00
Vincent Koc
22fc5a5442 Contracts: narrow codex catalog hint return type 2026-03-18 08:54:01 -07:00
Peter Steinberger
49b248a333 fix: skip plugin sdk dts in docker builds 2026-03-18 15:48:15 +00:00
Vincent Koc
ebb10c0852 Contracts: fix codex catalog hint assertion 2026-03-18 08:46:58 -07:00
Vincent Koc
6a381e80bc Contracts: stabilize provider plugin test imports 2026-03-18 08:44:47 -07:00
Peter Steinberger
a0e7a2fcc1 fix: repair rebased contract gate 2026-03-18 15:43:24 +00:00
Peter Steinberger
f6928617b7 test: stabilize gate regressions 2026-03-18 15:36:32 +00:00
Peter Steinberger
7943e83c6c fix: restore rebased full gate 2026-03-18 15:36:18 +00:00
Peter Steinberger
c0c3c4824d fix: checkpoint gate fixes before rebase 2026-03-18 15:36:18 +00:00
Peter Steinberger
e9b19ca1d1 fix: restore full gate after web-search rebase 2026-03-18 15:35:27 +00:00
Peter Steinberger
861fcb1575 fix: restore rebased full gate 2026-03-18 15:34:27 +00:00
Peter Steinberger
b5d2123156 fix: stabilize rebased full gate 2026-03-18 15:34:27 +00:00
Peter Steinberger
0cddb5fb7c fix: restore full gate 2026-03-18 15:34:27 +00:00
Tak Hoffman
ea476de1e4 Add plugin-sdk seam audit script 2026-03-18 10:16:21 -05:00
Tak Hoffman
5d41fd4497 test: extend plugin contract setup timeouts 2026-03-18 09:42:52 -05:00
Tak Hoffman
ca13256913 Deps: restore known-good tlon api install source 2026-03-18 08:50:02 -05:00
Tak Hoffman
4a44ca8f79 fix llm-task invalid thinking timeout 2026-03-18 08:33:40 -05:00
Tak Hoffman
c2402e48c9 Build: narrow tsdown unresolved import guard 2026-03-18 08:32:41 -05:00
2432 changed files with 164278 additions and 51437 deletions

View File

@@ -0,0 +1,87 @@
---
name: openclaw-ghsa-maintainer
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
---
# OpenClaw GHSA Maintainer
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
## Respect advisory guardrails
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
- Ask permission before any publish action.
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
## Fetch and inspect advisory state
Fetch the current advisory and the latest published npm version:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
npm view openclaw version --userconfig "$(mktemp)"
```
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
## Verify private fork PRs are closed
Before publishing, verify that the advisory's private fork has no open PRs:
```bash
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
gh pr list -R "$fork" --state open
```
The PR list must be empty before publish.
## Prepare advisory Markdown and JSON safely
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
Example pattern:
```bash
cat > /tmp/ghsa.desc.md <<'EOF'
<markdown description>
EOF
jq -n --rawfile desc /tmp/ghsa.desc.md \
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
> /tmp/ghsa.patch.json
```
## Apply PATCH calls in the correct sequence
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
- Use separate calls when the advisory requires both fields.
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
Example shape:
```bash
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
--input /tmp/ghsa.patch.json
```
## Publish and verify success
After publish, re-fetch the advisory and confirm:
- `state=published`
- `published_at` is set
- the description does not contain literal escaped `\\n`
Verification pattern:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
```
## Common GHSA footguns
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.

View File

@@ -0,0 +1,58 @@
---
name: openclaw-parallels-smoke
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
---
# OpenClaw Parallels Smoke
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
## Global rules
- Use the snapshot most closely matching the requested fresh baseline.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- Target the snapshot closest to `macOS 26.3.1 fresh`.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
## Windows flow
- Preferred entrypoint: `pnpm test:parallels:windows`
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
## Linux flow
- Preferred entrypoint: `pnpm test:parallels:linux`
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
## Discord roundtrip
- Discord roundtrip is optional and should be enabled with:
- `--discord-token-env`
- `--discord-guild-id`
- `--discord-channel-id`
- Keep the Discord token only in a host env var.
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.

View File

@@ -0,0 +1,75 @@
---
name: openclaw-pr-maintainer
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
---
# OpenClaw PR Maintainer
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
## Apply close and triage labels correctly
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
- Do not manually close plus manually comment for these reasons.
- `r:*` labels can be used on both issues and PRs.
- Current reasons:
- `r: skill`
- `r: support`
- `r: no-ci-pr`
- `r: too-many-prs`
- `r: testflight`
- `r: third-party-extension`
- `r: moltbook`
- `r: spam`
- `invalid`
- `dirty` for PRs only
## Enforce the bug-fix evidence bar
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. a fix that touches the implicated code path
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
## Handle GitHub text safely
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
## Search broadly before deciding
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
- Use `--repo openclaw/openclaw` with `--match title,body` first.
- Add `--match comments` when triaging follow-up discussion.
- Do not stop at the first 500 results when the task requires a full search.
Examples:
```bash
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
--json number,title,state,url,updatedAt -- "auto update" \
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
```
## Follow PR review and landing hygiene
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
## Extra safety
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.

View File

@@ -0,0 +1,74 @@
---
name: openclaw-release-maintainer
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
---
# OpenClaw Release Maintainer
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
- Ask permission before any npm publish or release step.
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
## Keep release channel naming aligned
- `stable`: tagged releases only, with npm dist-tag `latest`
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
## Handle versions and release files consistently
- Version locations include:
- `package.json`
- `apps/android/app/build.gradle.kts`
- `apps/ios/Sources/Info.plist`
- `apps/ios/Tests/Info.plist`
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
## Build changelog-backed release notes
- Changelog entries should be user-facing, not internal release-process notes.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- use release notes from the matching `CHANGELOG.md` version section
- attach at least the zip and dSYM zip, plus dmg if available
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first
- `### Fixes` deduped with user-facing fixes first
## Run publish-time validation
Before tagging or publishing, run:
```bash
node --import tsx scripts/release-check.ts
pnpm release:check
pnpm test:install:smoke
```
For a non-root smoke path:
```bash
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
## Use the right auth flow
- Core `openclaw` publish uses GitHub trusted publishing.
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## GHSA advisory work
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.

View File

@@ -0,0 +1,71 @@
---
name: openclaw-test-heap-leaks
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
## Workflow
1. Reproduce the failing shape first.
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
- If the report is about a specific shard or worker budget, preserve that shape.
2. Wait for repeated snapshots before concluding anything.
- Take at least two intervals from the same lane.
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
3. Classify the growth before choosing a fix.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
4. Fix the right layer.
- For retained transformed-module growth in shared workers:
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
- For real leaks:
- Patch the implicated test or runtime cleanup path.
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
5. Verify with the most direct proof.
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
## Heuristics
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
## Snapshot Comparison
- Direct comparison:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
- Auto-select earliest/latest snapshots per PID within one lane:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
- Useful flags:
- `--top 40`
- `--min-kb 32`
- `--pid 16133`
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
## Output Expectations
When using this skill, report:
- The exact reproduce command.
- Which lane and PID were compared.
- The dominant retained object families from the snapshot delta.
- Whether the issue is a real leak or shared-worker retained module growth.
- The concrete fix or impact-reduction patch.
- What you verified, and what snapshot overhead prevented you from verifying.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Test Heap Leaks"
short_description: "Investigate test OOMs with heap snapshots"
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
function printUsage() {
console.error(
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
);
console.error(
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
);
}
function fail(message) {
console.error(message);
process.exit(1);
}
function parseArgs(argv) {
const options = {
top: 30,
minKb: 64,
laneDir: null,
pid: null,
files: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--top") {
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--min-kb") {
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--lane-dir") {
options.laneDir = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === "--pid") {
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
options.files.push(arg);
}
if (!Number.isFinite(options.top) || options.top <= 0) {
fail("--top must be a positive integer");
}
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
fail("--min-kb must be a non-negative integer");
}
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
fail("--pid must be a positive integer");
}
return options;
}
function parseHeapFilename(filePath) {
const base = path.basename(filePath);
const match = base.match(
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
);
if (!match?.groups) {
return null;
}
return {
filePath,
pid: Number.parseInt(match.groups.pid, 10),
stamp: match.groups.stamp,
sequence: Number.parseInt(match.groups.seq, 10),
};
}
function resolvePair(options) {
if (options.laneDir) {
const entries = fs
.readdirSync(options.laneDir)
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
.filter((entry) => entry !== null)
.filter((entry) => options.pid === null || entry.pid === options.pid)
.toSorted((left, right) => {
if (left.pid !== right.pid) {
return left.pid - right.pid;
}
if (left.stamp !== right.stamp) {
return left.stamp.localeCompare(right.stamp);
}
return left.sequence - right.sequence;
});
if (entries.length === 0) {
fail(`No matching heap snapshots found in ${options.laneDir}`);
}
const groups = new Map();
for (const entry of entries) {
const group = groups.get(entry.pid) ?? [];
group.push(entry);
groups.set(entry.pid, group);
}
const candidates = Array.from(groups.values())
.map((group) => ({
pid: group[0].pid,
before: group[0],
after: group.at(-1),
count: group.length,
}))
.filter((entry) => entry.count >= 2);
if (candidates.length === 0) {
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
}
const chosen =
options.pid !== null
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
if (!chosen) {
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
}
return {
before: chosen.before.filePath,
after: chosen.after.filePath,
pid: chosen.pid,
snapshotCount: chosen.count,
};
}
if (options.files.length !== 2) {
printUsage();
process.exit(1);
}
return {
before: options.files[0],
after: options.files[1],
pid: null,
snapshotCount: 2,
};
}
function loadSummary(filePath) {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
const meta = data.snapshot?.meta;
if (!meta) {
fail(`Invalid heap snapshot: ${filePath}`);
}
const nodeFieldCount = meta.node_fields.length;
const typeNames = meta.node_types[0];
const strings = data.strings;
const typeIndex = meta.node_fields.indexOf("type");
const nameIndex = meta.node_fields.indexOf("name");
const selfSizeIndex = meta.node_fields.indexOf("self_size");
const summary = new Map();
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
const type = typeNames[data.nodes[offset + typeIndex]];
const name = strings[data.nodes[offset + nameIndex]];
const selfSize = data.nodes[offset + selfSizeIndex];
const key = `${type}\t${name}`;
const current = summary.get(key) ?? {
type,
name,
selfSize: 0,
count: 0,
};
current.selfSize += selfSize;
current.count += 1;
summary.set(key, current);
}
return {
nodeCount: data.snapshot.node_count,
summary,
};
}
function formatBytes(bytes) {
if (Math.abs(bytes) >= 1024 ** 2) {
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
}
if (Math.abs(bytes) >= 1024) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
return `${bytes} B`;
}
function formatDelta(bytes) {
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
}
function truncate(text, maxLength) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}`;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const pair = resolvePair(options);
const before = loadSummary(pair.before);
const after = loadSummary(pair.after);
const minBytes = options.minKb * 1024;
const rows = [];
for (const [key, next] of after.summary) {
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
const sizeDelta = next.selfSize - previous.selfSize;
const countDelta = next.count - previous.count;
if (sizeDelta < minBytes) {
continue;
}
rows.push({
type: next.type,
name: next.name,
sizeDelta,
countDelta,
afterSize: next.selfSize,
afterCount: next.count,
});
}
rows.sort(
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
);
console.log(`before: ${pair.before}`);
console.log(`after: ${pair.after}`);
if (pair.pid !== null) {
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
}
console.log(
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
);
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
console.log("");
if (rows.length === 0) {
console.log("No entries exceeded the minimum delta.");
return;
}
for (const row of rows.slice(0, options.top)) {
console.log(
[
formatDelta(row.sizeDelta).padStart(11),
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
row.type.padEnd(16),
truncate(row.name || "(empty)", 96),
].join(" "),
);
}
}
main();

View File

@@ -0,0 +1,108 @@
---
name: security-triage
description: Triage GitHub security advisories for OpenClaw with high-confidence close/keep decisions, exact tag and commit verification, trust-model checks, optional hardening notes, and a final reply ready to post and copy to clipboard.
---
# Security Triage
Use when reviewing OpenClaw security advisories, drafts, or GHSA reports.
Goal: high-confidence maintainers' triage without over-closing real issues or shipping unnecessary regressions.
## Close Bar
Close only if one of these is true:
- duplicate of an existing advisory or fixed issue
- invalid against shipped behavior
- out of scope under `SECURITY.md`
- fixed before any affected release/tag
Do not close only because `main` is fixed. If latest shipped tag or npm release is affected, keep it open until released or published with the right status.
## Required Reads
Before answering:
1. Read `SECURITY.md`.
2. Read the GHSA body with `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`.
3. Inspect the exact implicated code paths.
4. Verify shipped state:
- `git tag --sort=-creatordate | head`
- `npm view openclaw version --userconfig "$(mktemp)"`
- `git tag --contains <fix-commit>`
- if needed: `git show <tag>:path/to/file`
5. Search for canonical overlap:
- existing published GHSAs
- older fixed bugs
- same trust-model class already covered in `SECURITY.md`
## Review Method
For each advisory, decide:
- `close`
- `keep open`
- `keep open but narrow`
Check in this order:
1. Trust model
- Is the prerequisite already inside trusted host/local/plugin/operator state?
- Does `SECURITY.md` explicitly call this class out as out of scope or hardening-only?
2. Shipped behavior
- Is the bug present in the latest shipped tag or npm release?
- Was it fixed before release?
3. Exploit path
- Does the report show a real boundary bypass, not just prompt injection, local same-user control, or helper-level semantics?
4. Functional tradeoff
- If a hardening change would reduce intended user functionality, call that out before proposing it.
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
## Response Format
When preparing a maintainer-ready close reply:
1. Print the GHSA URL first.
2. Then draft a detailed response the maintainer can post.
3. Include:
- exact reason for close
- exact code refs
- exact shipped tag / release facts
- exact fix commit or canonical duplicate GHSA when applicable
- optional hardening note only if worthwhile and functionality-preserving
Keep tone firm, specific, non-defensive.
## Clipboard Step
After drafting the final post body, copy it:
```bash
pbcopy <<'EOF'
<final response>
EOF
```
Tell the user that the clipboard now contains the proposed response.
## Useful Commands
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
gh api /repos/openclaw/openclaw/security-advisories --paginate
git tag --sort=-creatordate | head -n 20
npm view openclaw version --userconfig "$(mktemp)"
git tag --contains <commit>
git show <tag>:<path>
gh search issues --repo openclaw/openclaw --match title,body,comments -- "<terms>"
gh search prs --repo openclaw/openclaw --match title,body,comments -- "<terms>"
```
## Decision Notes
- “fixed on main, unreleased” is usually not a close.
- “needs attacker-controlled trusted local state first” is usually out of scope.
- “same-host same-user process can already read/write local state” is usually out of scope.
- “helper function behaves differently than documented config semantics” is usually invalid.
- If only the severity is wrong but the bug is real, keep it open and narrow the impact in the reply.

View File

@@ -1,7 +1,7 @@
.git
.worktrees
# Sensitive files docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
# Sensitive files scripts/docker/setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
# into the project root; keep it out of the build context.
.env
.env.*

View File

@@ -7,7 +7,8 @@ body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
- type: dropdown
id: bug_type
attributes:
@@ -23,35 +24,35 @@ body:
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
1. Start OpenClaw 2026.2.17 with the attached config.
2. Send a Telegram thread reply in the affected chat.
3. Observe no reply and confirm the attached `reply target not found` log line.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen if the bug does not exist.
placeholder: Agent posts a reply in the same thread.
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead, including user-visible errors.
placeholder: No reply is posted; gateway logs "reply target not found".
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
validations:
required: true
- type: input
@@ -92,12 +93,6 @@ body:
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
validations:
required: true
- type: input
id: config_location
attributes:
label: Config file / key location
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
- type: textarea
id: provider_setup_details
attributes:
@@ -111,27 +106,28 @@ body:
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
render: shell
- type: textarea
id: impact
attributes:
label: Impact and severity
description: |
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, data risk, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on <version>
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
Affected: Telegram group users on 2026.2.17
Severity: High (blocks thread replies)
Frequency: 4/4 observed attempts
Consequence: Agents do not respond in the affected threads
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.

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"

7
.github/labeler.yml vendored
View File

@@ -165,7 +165,10 @@
- "Dockerfile.*"
- "docker-compose.yml"
- "docker-setup.sh"
- "setup-podman.sh"
- ".dockerignore"
- "scripts/docker/setup.sh"
- "scripts/podman/setup.sh"
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"
@@ -290,6 +293,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/synthetic/**"
"extensions: tavily":
- changed-files:
- any-glob-to-any-file:
- "extensions/tavily/**"
"extensions: talk-voice":
- changed-files:
- any-glob-to-any-file:

View File

@@ -11,7 +11,7 @@ Describe the problem and fix in 25 bullets:
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Refactor required for the fix
- [ ] Docs
- [ ] Security hardening
- [ ] Chore/infra

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,10 +4,11 @@ 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 }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -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
@@ -215,26 +217,39 @@ jobs:
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
- runtime: node
task: compat-node22
node_version: "22.x"
cache_key_suffix: "node22"
command: |
pnpm build
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:
- name: Skip bun lane on pull requests
if: github.event_name == 'pull_request' && matrix.runtime == 'bun'
run: echo "Skipping Bun compatibility lane on pull requests."
- name: Skip compatibility lanes on pull requests
if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
run: echo "Skipping push-only lane on pull requests."
- name: Checkout
if: github.event_name != 'pull_request' || matrix.runtime != 'bun'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
uses: ./.github/actions/setup-node-env
with:
node-version: "${{ matrix.node_version || '24.x' }}"
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
install-bun: "${{ matrix.runtime == 'bun' }}"
use-sticky-disk: "false"
- name: Configure Node test resources
if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
env:
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
@@ -248,12 +263,23 @@ 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
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
run: ${{ matrix.command }}
extension-fast:
name: "extension-fast (${{ matrix.extension }})"
name: "extension-fast"
needs: [docs-scope, changed-scope, changed-extensions]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
@@ -301,11 +327,8 @@ jobs:
- name: Strict TS build smoke
run: pnpm build:strict-smoke
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
plugin-extension-boundary:
name: "plugin-extension-boundary"
check-additional:
name: "check-additional"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
@@ -322,72 +345,89 @@ jobs:
use-sticky-disk: "false"
- name: Run plugin extension boundary guard
id: plugin_extension_boundary
continue-on-error: true
run: pnpm run lint:plugins:no-extension-imports
web-search-provider-boundary:
name: "web-search-provider-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run web search provider boundary guard
id: web_search_provider_boundary
continue-on-error: true
run: pnpm run lint:web-search-provider-boundaries
extension-src-outside-plugin-sdk-boundary:
name: "extension-src-outside-plugin-sdk-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension src boundary guard
id: extension_src_outside_plugin_sdk_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
extension-plugin-sdk-internal-boundary:
name: "extension-plugin-sdk-internal-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension plugin-sdk-internal guard
id: extension_plugin_sdk_internal_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-plugin-sdk-internal
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
run: pnpm lint:ui:no-raw-window-open
- name: Run gateway watch regression harness
id: gateway_watch_regression
continue-on-error: true
run: pnpm test:gateway:watch-regression
- name: Check config docs drift statefile
id: config_docs_drift
continue-on-error: true
run: pnpm config:docs:check
- name: Check plugin SDK API baseline drift
id: plugin_sdk_api_drift
continue-on-error: true
run: pnpm plugin-sdk:api:check
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
- name: Fail if any additional check failed
if: always()
env:
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
CONFIG_DOCS_DRIFT_OUTCOME: ${{ steps.config_docs_drift.outcome }}
PLUGIN_SDK_API_DRIFT_OUTCOME: ${{ steps.plugin_sdk_api_drift.outcome }}
run: |
failures=0
for result in \
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
"config-docs-drift|$CONFIG_DOCS_DRIFT_OUTCOME" \
"plugin-sdk-api-drift|$PLUGIN_SDK_API_DRIFT_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"
if [ "$outcome" != "success" ]; then
echo "::error title=${name} failed::${name} outcome: ${outcome}"
failures=1
fi
done
exit "$failures"
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
@@ -401,7 +441,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
@@ -416,34 +464,6 @@ jobs:
- name: Check CLI startup memory
run: pnpm test:startup:memory
gateway-watch-regression:
name: "gateway-watch-regression"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run gateway watch regression harness
run: pnpm test:gateway:watch-regression
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]
@@ -464,43 +484,9 @@ jobs:
- name: Check docs
run: pnpm check:docs
compat-node22:
name: "compat-node22"
needs: [docs-scope, changed-scope]
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node 22 compatibility environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.x"
cache-key-suffix: "node22"
install-bun: "false"
use-sticky-disk: "false"
- name: Configure Node 22 test resources
run: |
# Keep the compatibility lane aligned with the default Node test lane.
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
- name: Build under Node 22
run: pnpm build
- name: Run tests under Node 22
run: pnpm test
- name: Verify npm pack under Node 22
run: pnpm release:check
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -525,6 +511,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
@@ -601,8 +588,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:
@@ -722,6 +709,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
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -744,6 +742,9 @@ jobs:
with:
install-bun: "false"
- name: Build dist (macOS)
run: pnpm build
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
@@ -970,10 +971,14 @@ jobs:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
- task: test-play
command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
- task: test-third-party
command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
- task: build-play
command: ./gradlew --no-daemon :app:assemblePlayDebug
- task: build-third-party
command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -116,7 +116,7 @@ jobs:
- name: Build Android for CodeQL
if: matrix.language == 'java-kotlin'
working-directory: apps/android
run: ./gradlew --no-daemon :app:assembleDebug
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Build Swift for CodeQL
if: matrix.language == 'swift'

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
@@ -62,24 +64,65 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
# This smoke only validates that the build-arg path preinstalls selected
# extension deps without breaking image build or basic CLI startup. It
# does not exercise runtime loading/registration of diagnostics-otel.
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
OPENCLAW_EXTENSIONS=matrix
tags: openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Smoke test Dockerfile with extension build arg
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
- name: Build installer smoke image
uses: useblacksmith/build-push-action@v2

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

@@ -72,7 +72,7 @@ jobs:
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py
config-docs-drift:
generated-doc-baselines:
if: github.event_name == 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
@@ -87,3 +87,6 @@ jobs:
- name: Check config docs drift statefile
run: pnpm config:docs:check
- name: Check plugin SDK API baseline drift
run: pnpm plugin-sdk:api:check

6
.gitignore vendored
View File

@@ -31,6 +31,7 @@ apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
apps/android/.kotlin/
apps/android/benchmark/results/
# Bun build artifacts
*.bun-build
@@ -100,8 +101,6 @@ USER.md
/local/
package-lock.json
.claude/
.agents/
.agents
.agent/
skills-lock.json
@@ -135,3 +134,6 @@ ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__
ui/.vitest-attachments
docs/superpowers
# Deprecated changelog fragment workflow
changelog/fragments/

3
.npmrc
View File

@@ -1 +1,4 @@
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
node-linker=hoisted

203
AGENTS.md
View File

@@ -2,52 +2,17 @@
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
- GitHub linking footgun: dont wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Auto-close labels (issues and PRs)
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
- Do not manually close + manually comment for these reasons.
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
- `r:*` labels can be used on both issues and PRs.
- `r: skill`: close with guidance to publish skills on Clawhub.
- `r: support`: close with redirect to Discord support + stuck FAQ.
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
- `r: too-many-prs`: close when author exceeds active PR limit.
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
- `r: moltbook`: close + lock as off-topic (not affiliated).
- `r: spam`: close + lock as spam (`lock_reason: spam`).
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
## PR truthfulness and bug-fix validation
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
- Minimum merge gate for bug-fix PRs:
1. symptom evidence (repro/log/failing test),
2. verified root cause in code with file/line,
3. fix touches the implicated code path,
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename.
- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
@@ -106,15 +71,28 @@
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline artifacts live together under `docs/.generated/`.
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
- Plugin SDK API drift uses `pnpm plugin-sdk:api:gen` / `pnpm plugin-sdk:api:check`.
- If you change config schema/help or the public Plugin SDK surface, update the matching baseline artifact and keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
- Preferred landing bar for pushes to `main`: `pnpm check` and `pnpm test`, with a green result when feasible.
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface.
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
- Do not use scoped tests as permission to ignore plausibly related failures.
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
- Formatting/linting via Oxlint and Oxfmt.
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
@@ -124,20 +102,21 @@
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
## Release Channels (Naming)
## Release / Advisory Workflows
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
- dev: moving head on `main` (no tag; git checkout main).
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
- Release and publish remain explicit-approval actions even when using the skill.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already.
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/help/testing.md`.
@@ -149,7 +128,9 @@
## Commit & Pull Request Guidelines
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
@@ -158,105 +139,30 @@
- PR submission template (canonical): `.github/pull_request_template.md`
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
## Shorthand Commands
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
## Git Notes
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
## GitHub Search (`gh`)
- Prefer targeted keyword search before proposing new work or duplicating fixes.
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Structured output example:
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
## Security & Configuration Tips
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
## GHSA (Repo Advisory) Patch/Publish
- Before reviewing security advisories, read `SECURITY.md`.
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
- Private fork PRs must be closed:
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
## Local Runtime / Platform Notes
- Vocabulary: "makeup" = "mac app".
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
- Parallels macOS smoke playbook:
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Discord roundtrip smoke is opt-in. Pass `--discord-token-env <VAR> --discord-guild-id <guild> --discord-channel-id <channel>`; the harness will configure Discord in-guest, post a guest message, verify host-side visibility via the Discord REST API, post a fresh host-side message back into the channel, then verify `openclaw message read` sees it in-guest.
- Keep the Discord token in a host env var only. For Peters Mac Studio bot, fetch it into a temp env var from `~/.openclaw/openclaw.json` over SSH instead of hardcoding it in repo files/shell history.
- For Discord smoke on this snapshot: use `openclaw message send/read` via the installed wrapper, not `node openclaw.mjs message ...`; lazy `message` subcommands do not resolve the same way through the direct module entrypoint.
- For Discord guild allowlists: set `channels.discord.guilds` as one JSON object. Do not use dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated as array indexes.
- Avoid `prlctl enter` / expect for the Discord config phase; long lines get mangled. Use `prlctl exec --current-user /bin/sh -lc ...` with short commands or temp files.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
- Dont run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
- Parallels Windows smoke playbook:
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
- Parallels Linux smoke playbook:
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
@@ -271,6 +177,20 @@
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Voice wake forwarding tips:
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
## Collaboration / Safety Notes
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
@@ -281,41 +201,12 @@
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## Release Auth
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
## Changelog Release Notes
- When cutting a mac release with beta GitHub prerelease:
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
- Keep top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first.
- `### Fixes` deduped and ranked with user-facing fixes first.
- Before tagging/publishing, run:
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
@@ -23,10 +24,12 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
- Doctor/refactor: start splitting doctor provider checks into `src/commands/doctor/providers/*` by extracting Telegram first-run and group allowlist warnings into a provider-specific module, keeping the current setup guidance and warning behavior intact. Thanks @vincentkoc.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) Thanks @scoootscooob.
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
@@ -42,13 +45,40 @@ Docs: https://docs.openclaw.ai
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
- Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao.
- Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849)
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
- Telegram/topics: auto-rename DM forum topics on first message with LLM-generated labels, with per-account and per-DM `autoTopicLabel` overrides. (#51502) Thanks @Lukavyi.
- Docs/plugins: add the community wecom plugin listing to the docs catalog. (#29905) Thanks @sliverp.
- 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.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Telegram/setup: seed fresh setups with `channels.telegram.groups["*"].requireMention=true` so new bots stay mention-gated in groups unless you explicitly open them up. Thanks @vincentkoc.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
@@ -58,8 +88,15 @@ Docs: https://docs.openclaw.ai
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) Thanks @scoootscooob.
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
- 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.
- Gateway/Discord startup: load only configured channel plugins during gateway boot, and lazy-load Discord provider/session runtime setup so startup stops importing unrelated providers and trims cold-start delay. Thanks @vincentkoc.
- Security/exec: harden macOS allowlist resolution against wrapper and `env` spoofing, require fresh approval for inline interpreter eval with `tools.exec.strictInlineEval`, wrap Discord guild message bodies as untrusted external content, and add audit findings for risky exec approval and open-channel combinations.
- 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.
@@ -69,10 +106,15 @@ Docs: https://docs.openclaw.ai
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. (#46801) Thanks @vincentkoc.
- Web search/onboarding: clarify provider labels, key prompts, and missing-key notes so setup/configure more clearly names the required provider credential for Gemini, Kimi, Grok, Brave Search, Firecrawl, Perplexity, and Tavily. Thanks @vincentkoc.
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
- 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.
- Doctor/refactor: centralize built-in channel doctor semantics in one static capability registry with conservative fallback behavior for unknown/external channels, so future extension changes stop depending on scattered shared string checks. 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.
@@ -83,11 +125,15 @@ 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.
- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16.
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
@@ -97,14 +143,17 @@ Docs: https://docs.openclaw.ai
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) Thanks @luzhidong.
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #46532. Thanks @vincentkoc.
- Models/OpenAI Codex OAuth: start the remote manual-input race for Codex login and keep the pasted-input prompt aligned with the actual accepted values, so remote/VPS auth no longer stalls waiting on an unreachable localhost callback. (#51631) Thanks @cash-echo-bot.
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
- 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.
- Gateway/agent events: stop broadcasting false end-of-run `seq gap` errors to clients, and isolate node-driven ingress turns with per-turn run IDs so stale tail events cannot leak into later session runs. (#43751) Thanks @caesargattuso.
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
@@ -113,6 +162,7 @@ Docs: https://docs.openclaw.ai
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
- Gateway/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev.
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
@@ -129,6 +179,17 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
- Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant.
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
- Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
- 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
@@ -143,12 +204,43 @@ Docs: https://docs.openclaw.ai
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
- Synology Chat/multi-account: scope direct-message sessions by account and sender so identical webhook `user_id` values on different Synology accounts no longer share transcript or delivery state.
- 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.
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus.
- Plugins/update: let `openclaw plugins update <npm-spec>` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo.
- Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo.
- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo.
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
- 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 @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
@@ -160,6 +252,11 @@ Docs: https://docs.openclaw.ai
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
- 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
@@ -210,6 +307,7 @@ Docs: https://docs.openclaw.ai
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
@@ -236,6 +334,7 @@ Docs: https://docs.openclaw.ai
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
- Sessions/BlueBubbles/cron: persist outbound session routing and transcript mirroring for new targets, auto-create BlueBubbles chats before attachment sends, and only suppress isolated cron deliveries when the run started hours late instead of merely finishing late. (#50092)
### Breaking
@@ -338,6 +437,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
@@ -473,6 +573,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

@@ -83,7 +83,9 @@ Welcome to the lobster tank! 🦞
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR
@@ -96,6 +98,9 @@ Welcome to the lobster tank! 🦞
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why

View File

@@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@@ -49,7 +49,7 @@ Model note: while many providers/models are supported, for the best experience a
## Install (recommended)
Runtime: **Node ≥22**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@@ -62,7 +62,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node ≥22**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@@ -27,14 +27,34 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
./gradlew :app:assemblePlayDebug
./gradlew :app:installPlayDebug
./gradlew :app:testPlayDebugUnitTest
cd ../..
bun run android:bundle:release
```
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`.
Third-party debug flavor:
```bash
cd apps/android
./gradlew :app:assembleThirdPartyDebug
./gradlew :app:installThirdPartyDebug
./gradlew :app:testThirdPartyDebugUnitTest
```
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
Flavor-specific direct Gradle tasks:
```bash
cd apps/android
./gradlew :app:bundlePlayRelease
./gradlew :app:bundleThirdPartyRelease
```
## Kotlin Lint + Format
@@ -176,6 +196,48 @@ More details: `docs/platforms/android.md`.
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Google Play Restricted Permissions
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
- `READ_SMS`
- `SEND_SMS`
- `READ_CALL_LOG`
Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
Policy links:
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
Other Play-restricted surfaces to watch if added later:
- `ACCESS_BACKGROUND_LOCATION`
- `MANAGE_EXTERNAL_STORAGE`
- `QUERY_ALL_PACKAGES`
- `REQUEST_INSTALL_PACKAGES`
- `AccessibilityService`
Reference links:
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.

View File

@@ -65,14 +65,29 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026031400
versionName = "2026.3.14"
versionCode = 2026032000
versionName = "2026.3.20"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
}
create("thirdParty") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
}
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
@@ -140,8 +155,13 @@ androidComponents {
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "openclaw-$versionName-$buildType.apk"
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}

View File

@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission

View File

@@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun setForeground(value: Boolean) {
foreground = value
runtimeRef.value?.setForeground(value)
val runtime =
if (value && prefs.onboardingCompleted.value) {
ensureRuntime()
} else {
runtimeRef.value
}
runtime?.setForeground(value)
}
fun setDisplayName(value: String) {

View File

@@ -89,6 +89,8 @@ class NodeRuntime(
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
@@ -137,7 +139,9 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@@ -160,7 +164,9 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
@@ -566,43 +572,8 @@ class NodeRuntime(
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
// UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
if (!manualTls.value) return@collect
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(target)
seedLastDiscoveredGateway(list)
autoConnectIfNeeded()
}
}
@@ -627,11 +598,53 @@ class NodeRuntime(
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
if (value) {
reconnectPreferredGatewayOnForeground()
} else {
stopActiveVoiceSession()
}
}
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
if (list.isEmpty()) return
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
prefs.setLastDiscoveredStableId(list.first().stableId)
}
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port !in 1..65535) return null
return GatewayEndpoint.manual(host = host, port = port)
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return null
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return null
return endpoint
}
private fun autoConnectIfNeeded() {
if (didAutoConnect) return
if (_isConnected.value) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
didAutoConnect = true
connect(endpoint)
}
private fun reconnectPreferredGatewayOnForeground() {
if (_isConnected.value) return
if (_pendingGatewayTrust.value != null) return
if (connectedEndpoint != null) {
refreshGatewayConnection()
return
}
resolvePreferredGatewayEndpoint()?.let(::connect)
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}

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

@@ -75,7 +75,7 @@ class ChatController(
fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) }
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
fun applyMainSessionKey(mainSessionKey: String) {
@@ -84,11 +84,11 @@ class ChatController(
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
scope.launch { bootstrap(forceHealth = true) }
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
fun refresh() {
scope.launch { bootstrap(forceHealth = true) }
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
fun refreshSessions(limit: Int? = null) {
@@ -106,7 +106,9 @@ class ChatController(
if (key.isEmpty()) return
if (key == _sessionKey.value) return
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) }
// Keep the thread switch path lean: history + health are needed immediately,
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
}
fun sendMessage(
@@ -249,7 +251,7 @@ class ChatController(
}
}
private suspend fun bootstrap(forceHealth: Boolean) {
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
_errorText.value = null
_healthOk.value = false
clearPendingRuns()
@@ -271,7 +273,9 @@ class ChatController(
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
pollHealthIfNeeded(force = forceHealth)
fetchSessions(limit = 50)
if (refreshSessions) {
fetchSessions(limit = 50)
}
} catch (err: Throwable) {
_errorText.value = err.message
}

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

@@ -17,7 +17,9 @@ class ConnectionManager(
private val voiceWakeMode: () -> VoiceWakeMode,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@@ -78,7 +80,9 @@ class ConnectionManager(
NodeRuntimeFlags(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
callLogAvailable = callLogAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),

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

@@ -25,6 +25,8 @@ import kotlinx.serialization.json.put
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
) {
private data class BatterySnapshot(
val status: Int,
@@ -173,8 +175,8 @@ class DeviceHandler(
put(
"sms",
permissionStateJson(
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = canSendSms,
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
put(
@@ -215,8 +217,8 @@ class DeviceHandler(
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = callLogEnabled,
),
)
put(

View File

@@ -18,7 +18,9 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val callLogAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@@ -29,7 +31,9 @@ enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
SendSmsAvailable,
ReadSmsAvailable,
CallLogAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@@ -40,6 +44,7 @@ enum class NodeCapabilityAvailability {
CameraEnabled,
LocationEnabled,
SmsAvailable,
CallLogAvailable,
VoiceWakeEnabled,
MotionAvailable,
}
@@ -85,7 +90,10 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.CallLog.rawValue,
availability = NodeCapabilityAvailability.CallLogAvailable,
),
)
val all: List<InvokeCommandSpec> =
@@ -187,10 +195,15 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
availability = InvokeCommandAvailability.SendSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
availability = InvokeCommandAvailability.CallLogAvailable,
),
InvokeCommandSpec(
name = "debug.logs",
@@ -213,7 +226,8 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.Always -> true
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@@ -228,7 +242,9 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@@ -32,7 +32,9 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
@@ -162,6 +164,7 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
@@ -256,8 +259,8 @@ class InvokeDispatcher(
message = "PEDOMETER_UNAVAILABLE: step counter not available",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
InvokeCommandAvailability.SendSmsAvailable ->
if (sendSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
@@ -265,6 +268,24 @@ class InvokeDispatcher(
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.CallLogAvailable ->
if (callLogAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
class LocationHandler(
internal interface LocationDataSource {
fun hasFinePermission(context: Context): Boolean
fun hasCoarsePermission(context: Context): Boolean
suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload
}
private class DefaultLocationDataSource(
private val capture: LocationCaptureManager,
) : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
override fun hasCoarsePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload =
capture.getLocation(
desiredProviders = desiredProviders,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = isPrecise,
)
}
class LocationHandler private constructor(
private val appContext: Context,
private val location: LocationCaptureManager,
private val dataSource: LocationDataSource,
private val json: Json,
private val isForeground: () -> Boolean,
private val locationPreciseEnabled: () -> Boolean,
) {
fun hasFineLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
constructor(
appContext: Context,
location: LocationCaptureManager,
json: Json,
isForeground: () -> Boolean,
locationPreciseEnabled: () -> Boolean,
) : this(
appContext = appContext,
dataSource = DefaultLocationDataSource(location),
json = json,
isForeground = isForeground,
locationPreciseEnabled = locationPreciseEnabled,
)
fun hasCoarseLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
companion object {
internal fun forTesting(
appContext: Context,
dataSource: LocationDataSource,
json: Json = Json { ignoreUnknownKeys = true },
isForeground: () -> Boolean = { true },
locationPreciseEnabled: () -> Boolean = { true },
): LocationHandler =
LocationHandler(
appContext = appContext,
dataSource = dataSource,
json = json,
isForeground = isForeground,
locationPreciseEnabled = locationPreciseEnabled,
)
}
@@ -39,7 +97,7 @@ class LocationHandler(
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
@@ -49,9 +107,9 @@ class LocationHandler(
val preciseEnabled = locationPreciseEnabled()
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
}
val providers =
when (accuracy) {
@@ -61,7 +119,7 @@ class LocationHandler(
}
try {
val payload =
location.getLocation(
dataSource.fetchLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,

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

@@ -16,4 +16,16 @@ class SmsHandler(
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEARCH_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
}

View File

@@ -3,19 +3,27 @@ package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*
* Also provides SMS query functionality with READ_SMS permission.
*/
class SmsManager(private val context: Context) {
@@ -30,6 +38,30 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
/**
* Represents a single SMS message
*/
@Serializable
data class SmsMessage(
val id: Long,
val threadId: Long,
val address: String?,
val person: String?,
val date: Long,
val dateSent: Long,
val read: Boolean,
val type: Int,
val body: String?,
val status: Int,
)
data class SearchResult(
val ok: Boolean,
val messages: List<SmsMessage>,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
@@ -44,12 +76,30 @@ class SmsManager(private val context: Context) {
) : ParseResult()
}
internal data class QueryParams(
val startTime: Long? = null,
val endTime: Long? = null,
val contactName: String? = null,
val phoneNumber: String? = null,
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
internal sealed class QueryParseResult {
data class Ok(val params: QueryParams) : QueryParseResult()
data class Error(val error: String) : QueryParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@@ -88,6 +138,52 @@ class SmsManager(private val context: Context) {
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return QueryParseResult.Ok(QueryParams())
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
}
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
.coerceIn(1, 200)
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
// Validate time range
if (startTime != null && endTime != null && startTime > endTime) {
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
}
return QueryParseResult.Ok(QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
limit = limit,
offset = offset,
))
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
@@ -112,6 +208,25 @@ class SmsManager(private val context: Context) {
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
internal fun buildQueryPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
): String {
val messagesArray = json.encodeToString(messages)
val messagesElement = json.parseToJsonElement(messagesArray)
val payload = mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"count" to JsonPrimitive(messages.size),
"messages" to messagesElement
)
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
@@ -121,10 +236,28 @@ class SmsManager(private val context: Context) {
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadContactsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@@ -208,6 +341,20 @@ class SmsManager(private val context: Context) {
return results[Manifest.permission.SEND_SMS] == true
}
private suspend fun ensureReadSmsPermission(): Boolean {
if (hasReadSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
return results[Manifest.permission.READ_SMS] == true
}
private suspend fun ensureReadContactsPermission(): Boolean {
if (hasReadContactsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
return results[Manifest.permission.READ_CONTACTS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
@@ -227,4 +374,240 @@ class SmsManager(private val context: Context) {
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
/**
* search SMS messages with the specified parameters.
*
* @param paramsJson JSON with optional fields:
* - startTime (Long): Start time in milliseconds
* - endTime (Long): End time in milliseconds
* - contactName (String): Contact name to search
* - phoneNumber (String): Phone number to search (supports partial matching)
* - keyword (String): Keyword to search in message body
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
* - isRead (Boolean): Read status
* - limit (Int): Number of records to return (default: 25, range: 1-200)
* - offset (Int): Number of records to skip (default: 0)
* @return SearchResult containing the list of SMS messages or an error
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_UNAVAILABLE: telephony not available",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
)
}
if (!ensureReadSmsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
)
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = parseResult.error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
)
}
val params = (parseResult as QueryParseResult.Ok).params
return@withContext try {
// Get phone numbers from contact name if provided
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
if (!ensureReadContactsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
)
}
getPhoneNumbersFromContactName(params.contactName)
} else {
emptyList()
}
val messages = querySmsMessages(params, phoneNumbers)
SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
)
} catch (e: SecurityException) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
)
} catch (e: Throwable) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
)
}
}
/**
* Get all phone numbers associated with a contact name
*/
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
val phoneNumbers = mutableListOf<String>()
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("%$contactName%")
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
selection,
selectionArgs,
null
)
cursor?.use {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
}
}
return phoneNumbers
}
/**
* Query SMS messages based on the provided parameters
*/
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
// Build selection and selectionArgs
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
// Time range
if (params.startTime != null) {
selections.add("${Telephony.Sms.DATE} >= ?")
selectionArgs.add(params.startTime.toString())
}
if (params.endTime != null) {
selections.add("${Telephony.Sms.DATE} <= ?")
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
} else {
phoneNumbers
}
if (allPhoneNumbers.isNotEmpty()) {
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
"${Telephony.Sms.ADDRESS} LIKE ?"
}
selections.add("($addressSelection)")
allPhoneNumbers.forEach {
selectionArgs.add("%$it%")
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
}
// Type
if (params.type != null) {
selections.add("${Telephony.Sms.TYPE} = ?")
selectionArgs.add(params.type.toString())
}
// Read status
if (params.isRead != null) {
selections.add("${Telephony.Sms.READ} = ?")
selectionArgs.add(if (params.isRead) "1" else "0")
}
val selection = if (selections.isNotEmpty()) {
selections.joinToString(" AND ")
} else {
null
}
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
selectionArgs.toTypedArray()
} else {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
arrayOf(
Telephony.Sms._ID,
Telephony.Sms.THREAD_ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.PERSON,
Telephony.Sms.DATE,
Telephony.Sms.DATE_SENT,
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
),
selection,
selectionArgsArray,
sortOrder
)
cursor?.use {
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
var count = 0
while (it.moveToNext() && count < params.limit) {
val message = SmsMessage(
id = it.getLong(idIndex),
threadId = it.getLong(threadIdIndex),
address = it.getString(addressIndex),
person = it.getString(personIndex),
date = it.getLong(dateIndex),
dateSent = it.getLong(dateSentIndex),
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
)
messages.add(message)
count++
}
}
return messages
}
}

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

@@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
Search("sms.search"),
;
companion object {

View File

@@ -25,7 +25,7 @@ import ai.openclaw.app.MainViewModel
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
val webViewRef = remember { mutableStateOf<WebView?>(null) }
@@ -45,6 +45,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
modifier = modifier,
factory = {
WebView(context).apply {
visibility = if (visible) View.VISIBLE else View.INVISIBLE
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
@@ -127,6 +128,16 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
webViewRef.value = this
}
},
update = { webView ->
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
if (visible) {
webView.resumeTimers()
webView.onResume()
} else {
webView.onPause()
webView.pauseTimers()
}
},
)
}

View File

@@ -1,7 +1,7 @@
package ai.openclaw.app.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
@@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.mobileCardSurface
@@ -60,6 +62,7 @@ private enum class ConnectInputMode {
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -134,7 +137,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
}
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
val statusLabel = gatewayStatusForDisplay(statusText)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
@@ -279,6 +283,46 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
}
if (showDiagnostics) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileWarningSoft,
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.25f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Last gateway error", style = mobileHeadline, color = mobileWarning)
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
Text("OpenClaw Android ${openClawAndroidVersionLabel()}", style = mobileCaption1, color = mobileTextSecondary)
Button(
onClick = {
copyGatewayDiagnosticsReport(
context = context,
screen = "connect tab",
gatewayAddress = activeEndpoint,
statusText = statusLabel,
)
},
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(12.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileCardSurface,
contentColor = mobileWarning,
),
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.3f)),
) {
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),

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

@@ -0,0 +1,77 @@
package ai.openclaw.app.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
import ai.openclaw.app.BuildConfig
internal fun openClawAndroidVersionLabel(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
}
internal fun gatewayStatusForDisplay(statusText: String): String {
return statusText.trim().ifEmpty { "Offline" }
}
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower != "offline" && !lower.contains("connecting")
}
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("pair") || lower.contains("approve")
}
internal fun buildGatewayDiagnosticsReport(
screen: String,
gatewayAddress: String,
statusText: String,
): String {
val device =
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { "Android" }
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
val status = gatewayStatusForDisplay(statusText)
return """
Help diagnose this OpenClaw Android gateway connection failure.
Please:
- pick one route only: same machine, same LAN, Tailscale, or public URL
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
- quote the exact app status/error below
- tell me whether `openclaw devices list` should show a pending pairing request
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
- give the next exact command or tap
Debug info:
- screen: $screen
- app version: ${openClawAndroidVersionLabel()}
- device: $device
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
- gateway address: $endpoint
- status/error: $status
""".trimIndent()
}
internal fun copyGatewayDiagnosticsReport(
context: Context,
screen: String,
gatewayAddress: String,
statusText: String,
) {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
val report = buildGatewayDiagnosticsReport(screen = screen, gatewayAddress = gatewayAddress, statusText = statusText)
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostics", report))
Toast.makeText(context, "Copied gateway diagnostics", Toast.LENGTH_SHORT).show()
}

View File

@@ -9,6 +9,7 @@ import android.hardware.SensorManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -60,6 +61,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
@@ -91,6 +93,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.node.DeviceNotificationListenerService
@@ -236,8 +239,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val smsAvailable =
remember(context) {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
BuildConfig.OPENCLAW_ENABLE_SMS &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val motionAvailable =
remember(context) {
hasMotionCapabilities(context)
@@ -287,11 +292,15 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
var enableSms by
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
mutableStateOf(
smsAvailable &&
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS)
)
}
var enableCallLog by
rememberSaveable {
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
}
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
@@ -309,7 +318,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
PermissionToggle.CallLog -> enableCallLog = enabled
PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable
}
}
@@ -336,8 +345,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
!motionPermissionRequired ||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
!smsAvailable ||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS))
PermissionToggle.CallLog ->
!callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@@ -361,6 +373,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableSms,
enableCallLog,
smsAvailable,
callLogAvailable,
motionAvailable,
) {
val enabled = mutableListOf<String>()
@@ -375,7 +388,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (callLogAvailable && enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
}
@@ -604,6 +617,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
callLogAvailable = callLogAvailable,
enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
@@ -698,16 +712,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
requestPermissionToggle(
PermissionToggle.Sms,
checked,
listOf(Manifest.permission.SEND_SMS),
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
)
}
},
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
if (!callLogAvailable) {
setPermissionToggleEnabled(PermissionToggle.CallLog, false)
} else {
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
}
},
)
OnboardingStep.FinalCheck ->
@@ -1299,6 +1317,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
callLogAvailable: Boolean,
enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
@@ -1437,20 +1456,24 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "SMS",
subtitle = "Send text messages via the gateway",
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
if (callLogAvailable) {
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
}
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
@@ -1511,6 +1534,12 @@ private fun FinalStep(
enabledPermissions: String,
methodLabel: String,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val gatewayAddress = parsedGateway?.displayUrl ?: "Invalid gateway URL"
val statusLabel = gatewayStatusForDisplay(statusText)
val showDiagnostics = gatewayStatusHasDiagnostics(statusText)
val pairingRequired = gatewayStatusLooksLikePairing(statusText)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Review", style = onboardingTitle1Style, color = onboardingText)
@@ -1523,7 +1552,7 @@ private fun FinalStep(
SummaryCard(
icon = Icons.Default.Cloud,
label = "Gateway",
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
value = gatewayAddress,
accentColor = Color(0xFF7C5AC7),
)
SummaryCard(
@@ -1607,7 +1636,7 @@ private fun FinalStep(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingWarningSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) {
Column(
modifier = Modifier.padding(14.dp),
@@ -1632,13 +1661,66 @@ private fun FinalStep(
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
Text(
if (pairingRequired) "Pairing Required" else "Connection Failed",
style = onboardingHeadlineStyle,
color = onboardingWarning,
)
Text(
if (pairingRequired) {
"Approve this phone on the gateway host, or copy the report below."
} else {
"Copy this report and give it to your Claw."
},
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
}
}
CommandBlock("openclaw devices list")
CommandBlock("openclaw devices approve <requestId>")
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
if (showDiagnostics) {
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingCommandBg,
border = BorderStroke(1.dp, onboardingCommandBorder),
) {
Text(
statusLabel,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
color = onboardingCommandText,
)
}
Text(
"OpenClaw Android ${openClawAndroidVersionLabel()}",
style = onboardingCaption1Style,
color = onboardingTextSecondary,
)
Button(
onClick = {
copyGatewayDiagnosticsReport(
context = context,
screen = "onboarding final check",
gatewayAddress = gatewayAddress,
statusText = statusLabel,
)
},
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = onboardingSurface, contentColor = onboardingWarning),
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.3f)),
) {
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Copy Report for Claw", style = onboardingCalloutStyle.copy(fontWeight = FontWeight.Bold))
}
}
if (pairingRequired) {
CommandBlock("openclaw devices list")
CommandBlock("openclaw devices approve <requestId>")
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
}
}

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

@@ -39,7 +39,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -68,10 +70,19 @@ private enum class StatusVisual {
@Composable
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
// Stop TTS when user navigates away from voice tab
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
// alive after the first visit so repeated tab switches do not rebuild their UI trees.
LaunchedEffect(activeTab) {
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
if (activeTab == HomeTab.Chat) {
chatTabStarted = true
}
if (activeTab == HomeTab.Screen) {
screenTabStarted = true
}
}
val statusText by viewModel.statusText.collectAsState()
@@ -120,11 +131,35 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
.consumeWindowInsets(innerPadding)
.background(mobileBackgroundGradient),
) {
if (chatTabStarted) {
Box(
modifier =
Modifier
.matchParentSize()
.alpha(if (activeTab == HomeTab.Chat) 1f else 0f)
.zIndex(if (activeTab == HomeTab.Chat) 1f else 0f),
) {
ChatSheet(viewModel = viewModel)
}
}
if (screenTabStarted) {
ScreenTabScreen(
viewModel = viewModel,
visible = activeTab == HomeTab.Screen,
modifier =
Modifier
.matchParentSize()
.alpha(if (activeTab == HomeTab.Screen) 1f else 0f)
.zIndex(if (activeTab == HomeTab.Screen) 1f else 0f),
)
}
when (activeTab) {
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
HomeTab.Chat -> if (!chatTabStarted) ChatSheet(viewModel = viewModel)
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
HomeTab.Screen -> Unit
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
}
}
@@ -132,16 +167,19 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
}
@Composable
private fun ScreenTabScreen(viewModel: MainViewModel) {
private fun ScreenTabScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
val isConnected by viewModel.isConnected.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
var refreshedForCurrentConnection by rememberSaveable(isConnected) { mutableStateOf(false) }
LaunchedEffect(isConnected, visible, refreshedForCurrentConnection) {
if (visible && isConnected && !refreshedForCurrentConnection) {
viewModel.refreshHomeCanvasOverviewIfConnected()
refreshedForCurrentConnection = true
}
}
Box(modifier = Modifier.fillMaxSize()) {
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
Box(modifier = modifier.fillMaxSize()) {
CanvasScreen(viewModel = viewModel, visible = visible, modifier = Modifier.fillMaxSize())
}
}

View File

@@ -149,8 +149,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
val smsPermissionAvailable =
remember {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
BuildConfig.OPENCLAW_ENABLE_SMS &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val photosPermission =
if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES
@@ -247,12 +249,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
viewModel.refreshGatewayConnection()
}
@@ -287,6 +293,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
}
}
@@ -507,7 +515,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
colors = listItemColors,
headlineContent = { Text("SMS", style = mobileHeadline) },
supportingContent = {
Text("Send SMS from this device.", style = mobileCallout)
Text("Send and search SMS from this device.", style = mobileCallout)
},
trailingContent = {
Button(
@@ -515,7 +523,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
}
},
colors = settingsPrimaryButtonColors(),
@@ -616,31 +624,33 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
if (callLogPermissionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
if (motionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(

View File

@@ -63,7 +63,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
LaunchedEffect(mainSessionKey) {
viewModel.loadChat(mainSessionKey)
viewModel.refreshChatSessions(limit = 200)
}
val context = LocalContext.current

View File

@@ -1,338 +0,0 @@
package ai.openclaw.app.voice
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.*
import org.json.JSONObject
import kotlin.math.max
/**
* Streams text chunks to ElevenLabs WebSocket API and plays audio in real-time.
*
* Usage:
* 1. Create instance with voice/API config
* 2. Call [start] to open WebSocket + AudioTrack
* 3. Call [sendText] with incremental text chunks as they arrive
* 4. Call [finish] when the full response is ready (sends EOS to ElevenLabs)
* 5. Call [stop] to cancel/cleanup at any time
*
* Audio playback begins as soon as the first audio chunk arrives from ElevenLabs,
* typically within ~100ms of the first text chunk for eleven_flash_v2_5.
*
* Note: eleven_v3 does NOT support WebSocket streaming. Use eleven_flash_v2_5
* or eleven_flash_v2 for lowest latency.
*/
class ElevenLabsStreamingTts(
private val scope: CoroutineScope,
private val voiceId: String,
private val apiKey: String,
private val modelId: String = "eleven_flash_v2_5",
private val outputFormat: String = "pcm_24000",
private val sampleRate: Int = 24000,
) {
companion object {
private const val TAG = "ElevenLabsStreamTTS"
private const val BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech"
/** Models that support WebSocket input streaming */
val STREAMING_MODELS = setOf(
"eleven_flash_v2_5",
"eleven_flash_v2",
"eleven_multilingual_v2",
"eleven_turbo_v2_5",
"eleven_turbo_v2",
"eleven_monolingual_v1",
)
fun supportsStreaming(modelId: String): Boolean = modelId in STREAMING_MODELS
}
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private var webSocket: WebSocket? = null
private var audioTrack: AudioTrack? = null
private var trackStarted = false
private var client: OkHttpClient? = null
@Volatile private var stopped = false
@Volatile private var finished = false
@Volatile var hasReceivedAudio = false
private set
private var drainJob: Job? = null
// Track text already sent so we only send incremental chunks
private var sentTextLength = 0
@Volatile private var wsReady = false
private val pendingText = mutableListOf<String>()
/**
* Open the WebSocket connection and prepare AudioTrack.
* Must be called before [sendText].
*/
fun start() {
stopped = false
finished = false
hasReceivedAudio = false
sentTextLength = 0
trackStarted = false
wsReady = false
sentFullText = ""
synchronized(pendingText) { pendingText.clear() }
// Prepare AudioTrack
val minBuffer = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
val bufferSize = max(minBuffer * 2, 8 * 1024)
val track = AudioTrack(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build(),
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build(),
bufferSize,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE,
)
if (track.state != AudioTrack.STATE_INITIALIZED) {
track.release()
Log.e(TAG, "AudioTrack init failed")
return
}
audioTrack = track
_isPlaying.value = true
// Open WebSocket
val url = "$BASE_URL/$voiceId/stream-input?model_id=$modelId&output_format=$outputFormat"
val okClient = OkHttpClient.Builder()
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
client = okClient
val request = Request.Builder()
.url(url)
.header("xi-api-key", apiKey)
.build()
webSocket = okClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected")
// Send initial config with voice settings
val config = JSONObject().apply {
put("text", " ")
put("voice_settings", JSONObject().apply {
put("stability", 0.5)
put("similarity_boost", 0.8)
put("use_speaker_boost", false)
})
put("generation_config", JSONObject().apply {
put("chunk_length_schedule", org.json.JSONArray(listOf(120, 160, 250, 290)))
})
}
webSocket.send(config.toString())
wsReady = true
// Flush any text that was queued before WebSocket was ready
synchronized(pendingText) {
for (queued in pendingText) {
val msg = JSONObject().apply { put("text", queued) }
webSocket.send(msg.toString())
Log.d(TAG, "flushed queued chunk: ${queued.length} chars")
}
pendingText.clear()
}
// Send deferred EOS if finish() was called before WebSocket was ready
if (finished) {
val eos = JSONObject().apply { put("text", "") }
webSocket.send(eos.toString())
Log.d(TAG, "sent deferred EOS")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (stopped) return
try {
val json = JSONObject(text)
val audio = json.optString("audio", "")
if (audio.isNotEmpty()) {
val pcmBytes = Base64.decode(audio, Base64.DEFAULT)
writeToTrack(pcmBytes)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing WebSocket message: ${e.message}")
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket error: ${t.message}")
stopped = true
cleanup()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: $code $reason")
// Wait for AudioTrack to finish playing buffered audio, then cleanup
drainJob = scope.launch(Dispatchers.IO) {
drainAudioTrack()
cleanup()
}
}
})
}
/**
* Send incremental text. Call with the full accumulated text so far —
* only the new portion (since last send) will be transmitted.
*/
// Track the full text we've sent so we can detect replacement vs append
private var sentFullText = ""
/**
// If we already sent a superset of this text, it's just a stale/out-of-order
// event from a different thread — not a real divergence. Ignore it.
if (sentFullText.startsWith(fullText)) return true
* Returns true if text was accepted, false if text diverged (caller should restart).
*/
@Synchronized
fun sendText(fullText: String): Boolean {
if (stopped) return false
if (finished) return true // Already finishing — not a diverge, don't restart
// Detect text replacement: if the new text doesn't start with what we already sent,
// the stream has diverged (e.g., tool call interrupted and text was replaced).
if (sentFullText.isNotEmpty() && !fullText.startsWith(sentFullText)) {
// If we already sent a superset of this text, it's just a stale/out-of-order
// event from a different thread — not a real divergence. Ignore it.
if (sentFullText.startsWith(fullText)) return true
Log.d(TAG, "text diverged — sent='${sentFullText.take(60)}' new='${fullText.take(60)}'")
return false
}
if (fullText.length > sentTextLength) {
val newText = fullText.substring(sentTextLength)
sentTextLength = fullText.length
sentFullText = fullText
val ws = webSocket
if (ws != null && wsReady) {
val msg = JSONObject().apply { put("text", newText) }
ws.send(msg.toString())
Log.d(TAG, "sent chunk: ${newText.length} chars")
} else {
// Queue if WebSocket not connected yet (ws null = still connecting, wsReady false = handshake pending)
synchronized(pendingText) { pendingText.add(newText) }
Log.d(TAG, "queued chunk: ${newText.length} chars (ws not ready)")
}
}
return true
}
/**
* Signal that no more text is coming. Sends EOS to ElevenLabs.
* The WebSocket will close after generating remaining audio.
*/
@Synchronized
fun finish() {
if (stopped || finished) return
finished = true
val ws = webSocket
if (ws != null && wsReady) {
// Send empty text to signal end of stream
val eos = JSONObject().apply { put("text", "") }
ws.send(eos.toString())
Log.d(TAG, "sent EOS")
}
// else: WebSocket not ready yet; onOpen will send EOS after flushing queued text
}
/**
* Immediately stop playback and close everything.
*/
fun stop() {
stopped = true
finished = true
drainJob?.cancel()
drainJob = null
webSocket?.cancel()
webSocket = null
val track = audioTrack
audioTrack = null
if (track != null) {
try {
track.pause()
track.flush()
track.release()
} catch (_: Throwable) {}
}
_isPlaying.value = false
client?.dispatcher?.executorService?.shutdown()
client = null
}
private fun writeToTrack(pcmBytes: ByteArray) {
val track = audioTrack ?: return
if (stopped) return
// Start playback on first audio chunk — avoids underrun
if (!trackStarted) {
track.play()
trackStarted = true
hasReceivedAudio = true
Log.d(TAG, "AudioTrack started on first chunk")
}
var offset = 0
while (offset < pcmBytes.size && !stopped) {
val wrote = track.write(pcmBytes, offset, pcmBytes.size - offset)
if (wrote <= 0) {
if (stopped) return
Log.w(TAG, "AudioTrack write returned $wrote")
break
}
offset += wrote
}
}
private fun drainAudioTrack() {
if (stopped) return
// Wait up to 10s for audio to finish playing
val deadline = System.currentTimeMillis() + 10_000
while (!stopped && System.currentTimeMillis() < deadline) {
// Check if track is still playing
val track = audioTrack ?: return
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) return
try {
Thread.sleep(100)
} catch (_: InterruptedException) {
return
}
}
}
private fun cleanup() {
val track = audioTrack
audioTrack = null
if (track != null) {
try {
track.stop()
track.release()
} catch (_: Throwable) {}
}
_isPlaying.value = false
client?.dispatcher?.executorService?.shutdown()
client = null
}
}

View File

@@ -1,98 +0,0 @@
package ai.openclaw.app.voice
import android.media.MediaDataSource
import kotlin.math.min
internal class StreamingMediaDataSource : MediaDataSource() {
private data class Chunk(val start: Long, val data: ByteArray)
private val lock = Object()
private val chunks = ArrayList<Chunk>()
private var totalSize: Long = 0
private var closed = false
private var finished = false
private var lastReadIndex = 0
fun append(data: ByteArray) {
if (data.isEmpty()) return
synchronized(lock) {
if (closed || finished) return
val chunk = Chunk(totalSize, data)
chunks.add(chunk)
totalSize += data.size.toLong()
lock.notifyAll()
}
}
fun finish() {
synchronized(lock) {
if (closed) return
finished = true
lock.notifyAll()
}
}
fun fail() {
synchronized(lock) {
closed = true
lock.notifyAll()
}
}
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position < 0) return -1
synchronized(lock) {
while (!closed && !finished && position >= totalSize) {
lock.wait()
}
if (closed) return -1
if (position >= totalSize && finished) return -1
val available = (totalSize - position).toInt()
val toRead = min(size, available)
var remaining = toRead
var destOffset = offset
var pos = position
var index = findChunkIndex(pos)
while (remaining > 0 && index < chunks.size) {
val chunk = chunks[index]
val inChunkOffset = (pos - chunk.start).toInt()
if (inChunkOffset >= chunk.data.size) {
index++
continue
}
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
remaining -= copyLen
destOffset += copyLen
pos += copyLen
if (inChunkOffset + copyLen >= chunk.data.size) {
index++
}
}
return toRead - remaining
}
}
override fun getSize(): Long = -1
override fun close() {
synchronized(lock) {
closed = true
lock.notifyAll()
}
}
private fun findChunkIndex(position: Long): Int {
var index = lastReadIndex
while (index < chunks.size) {
val chunk = chunks[index]
if (position < chunk.start + chunk.data.size) break
index++
}
lastReadIndex = index
return index
}
}

View File

@@ -4,116 +4,23 @@ import ai.openclaw.app.normalizeMainKey
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
internal data class TalkModeGatewayConfigState(
val activeProvider: String,
val normalizedPayload: Boolean,
val missingResolvedPayload: Boolean,
val mainSessionKey: String,
val defaultVoiceId: String?,
val voiceAliases: Map<String, String>,
val defaultModelId: String,
val defaultOutputFormat: String,
val apiKey: String?,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
)
internal object TalkModeGatewayConfigParser {
private const val defaultTalkProvider = "elevenlabs"
fun parse(
config: JsonObject?,
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState {
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
return TalkModeGatewayConfigState(
activeProvider = activeProvider,
normalizedPayload = selection?.normalizedPayload == true,
missingResolvedPayload = talk != null && selection == null,
mainSessionKey = mainKey,
defaultVoiceId =
if (activeProvider == defaultProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
},
voiceAliases = aliases,
defaultModelId = model ?: defaultModelIdFallback,
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = interrupt,
silenceTimeoutMs = silenceTimeoutMs,
)
}
fun fallback(
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState =
TalkModeGatewayConfigState(
activeProvider = defaultProvider,
normalizedPayload = false,
missingResolvedPayload = false,
mainSessionKey = "main",
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
voiceAliases = emptyMap(),
defaultModelId = defaultModelIdFallback,
defaultOutputFormat = defaultOutputFormatFallback,
apiKey = envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = null,
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
)
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
selectResolvedTalkProviderConfig(talk)?.let { return it }
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
return null
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
)
}
@@ -127,26 +34,8 @@ internal object TalkModeGatewayConfigParser {
}
return timeout.toLong()
}
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
val resolved = talk["resolved"].asObjectOrNull() ?: return null
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
return TalkProviderConfigSelection(
provider = providerId,
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
normalizedPayload = true,
)
}
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
}
private fun normalizeTalkAliasKey(value: String): String =
value.trim().lowercase()
private fun JsonElement?.asStringOrNull(): String? =
this?.let { element ->
element as? JsonPrimitive

View File

@@ -1,122 +0,0 @@
package ai.openclaw.app.voice
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
internal data class TalkModeResolvedVoice(
val voiceId: String?,
val fallbackVoiceId: String?,
val defaultVoiceId: String?,
val currentVoiceId: String?,
val selectedVoiceName: String? = null,
)
internal object TalkModeVoiceResolver {
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = normalizeAliasKey(trimmed)
voiceAliases[normalized]?.let { return it }
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
return if (isLikelyVoiceId(trimmed)) trimmed else null
}
suspend fun resolveVoiceId(
preferred: String?,
fallbackVoiceId: String?,
defaultVoiceId: String?,
currentVoiceId: String?,
voiceOverrideActive: Boolean,
listVoices: suspend () -> List<ElevenLabsVoice>,
): TalkModeResolvedVoice {
val trimmed = preferred?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return TalkModeResolvedVoice(
voiceId = trimmed,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
if (!fallbackVoiceId.isNullOrBlank()) {
return TalkModeResolvedVoice(
voiceId = fallbackVoiceId,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
val first = listVoices().firstOrNull()
if (first == null) {
return TalkModeResolvedVoice(
voiceId = null,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
return TalkModeResolvedVoice(
voiceId = first.voiceId,
fallbackVoiceId = first.voiceId,
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
selectedVoiceName = first.name,
)
}
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
return withContext(Dispatchers.IO) {
val url = URL("https://api.elevenlabs.io/v1/voices")
val conn = url.openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("xi-api-key", apiKey)
val code = conn.responseCode
val stream = if (code >= 400) conn.errorStream else conn.inputStream
val data = stream?.use { it.readBytes() } ?: byteArrayOf()
if (code >= 400) {
val message = data.toString(Charsets.UTF_8)
throw IllegalStateException("ElevenLabs voices failed: $code $message")
}
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
voices.mapNotNull { entry ->
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()
ElevenLabsVoice(voiceId, name)
}
} finally {
conn.disconnect()
}
}
}
private fun isLikelyVoiceId(value: String): Boolean {
if (value.length < 10) return false
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
private fun normalizeAliasKey(value: String): String =
value.trim().lowercase()
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -0,0 +1,13 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.SEND_SMS"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_SMS"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_CALL_LOG"
tools:node="remove" />
</manifest>

View File

@@ -26,7 +26,6 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
)
private val optionalCapabilities =
@@ -34,6 +33,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Camera.rawValue,
OpenClawCapability.Location.rawValue,
OpenClawCapability.Sms.rawValue,
OpenClawCapability.CallLog.rawValue,
OpenClawCapability.VoiceWake.rawValue,
OpenClawCapability.Motion.rawValue,
)
@@ -52,7 +52,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val optionalCommands =
@@ -64,6 +63,8 @@ class InvokeCommandRegistryTest {
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
OpenClawSmsCommand.Search.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@@ -83,7 +84,9 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
callLogAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@@ -108,7 +111,9 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
callLogAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
@@ -125,7 +130,9 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
sendSmsAvailable = false,
readSmsAvailable = false,
callLogAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
@@ -137,10 +144,58 @@ class InvokeCommandRegistryTest {
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
@Test
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
val readOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun advertisedCommands_excludesCallLogWhenUnavailable() {
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false))
assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue))
}
@Test
fun advertisedCapabilities_excludesCallLogWhenUnavailable() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false))
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
callLogAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@@ -149,7 +204,9 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
callLogAvailable = callLogAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,

View File

@@ -0,0 +1,88 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LocationHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = false,
coarseGranted = false,
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleLocationGet_requiresForegroundBeforeLocationPermission() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
),
isForeground = { false },
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code)
}
@Test
fun hasFineLocationPermission_reflectsDataSource() {
val denied =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true),
)
assertFalse(denied.hasFineLocationPermission())
assertTrue(denied.hasCoarseLocationPermission())
val granted =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false),
)
assertTrue(granted.hasFineLocationPermission())
assertFalse(granted.hasCoarseLocationPermission())
}
}
private class FakeLocationDataSource(
private val fineGranted: Boolean,
private val coarseGranted: Boolean,
) : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean = fineGranted
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
throw IllegalStateException(
"LocationHandlerTest: fetchLocation must not run in this scenario",
)
}
}

View File

@@ -88,4 +88,95 @@ class SmsManagerTest {
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
@Test
fun parseQueryParamsAcceptsEmptyPayload() {
val result = SmsManager.parseQueryParams(null, json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(25, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsNonObjectJson() {
val result = SmsManager.parseQueryParams("[]", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsParsesLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(10, ok.params.limit)
assertEquals(5, ok.params.offset)
}
@Test
fun parseQueryParamsClampsLimitRange() {
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
}
@Test
fun parseQueryParamsParsesPhoneNumber() {
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("+1234567890", ok.params.phoneNumber)
}
@Test
fun parseQueryParamsParsesContactName() {
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("lixuankai", ok.params.contactName)
}
@Test
fun parseQueryParamsParsesKeyword() {
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("test", ok.params.keyword)
}
@Test
fun parseQueryParamsParsesTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1000L, ok.params.startTime)
assertEquals(2000L, ok.params.endTime)
}
@Test
fun parseQueryParamsParsesType() {
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1, ok.params.type)
}
@Test
fun parseQueryParamsParsesReadStatus() {
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
}

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

@@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

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

@@ -1,100 +0,0 @@
package ai.openclaw.app.voice
import java.io.File
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
@Serializable
private data class TalkConfigContractFixture(
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
) {
@Serializable
data class SelectionCase(
val id: String,
val defaultProvider: String,
val payloadValid: Boolean,
val expectedSelection: ExpectedSelection? = null,
val talk: JsonObject,
)
@Serializable
data class ExpectedSelection(
val provider: String,
val normalizedPayload: Boolean,
val voiceId: String? = null,
val apiKey: String? = null,
)
@Serializable
data class TimeoutCase(
val id: String,
val fallback: Long,
val expectedTimeoutMs: Long,
val talk: JsonObject,
)
}
class TalkModeConfigContractTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun selectionFixtures() {
for (fixture in loadFixtures().selectionCases) {
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
val expected = fixture.expectedSelection
if (expected == null) {
assertNull(fixture.id, selection)
continue
}
assertNotNull(fixture.id, selection)
assertEquals(fixture.id, expected.provider, selection?.provider)
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
assertEquals(
fixture.id,
expected.voiceId,
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
)
assertEquals(
fixture.id,
expected.apiKey,
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
)
assertEquals(fixture.id, true, fixture.payloadValid)
}
}
@Test
fun timeoutFixtures() {
for (fixture in loadFixtures().timeoutCases) {
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
}
}
private fun loadFixtures(): TalkConfigContractFixture {
val fixturePath = findFixtureFile()
return json.decodeFromString(File(fixturePath).readText())
}
private fun findFixtureFile(): String {
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
var current = File(startDir).absoluteFile
while (true) {
val candidate = File(current, "test-fixtures/talk-config-contract.json")
if (candidate.exists()) {
return candidate.absolutePath
}
current = current.parentFile ?: break
}
error("talk-config-contract.json not found from $startDir")
}
}

View File

@@ -2,135 +2,37 @@ package ai.openclaw.app.voice
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TalkModeConfigParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun prefersCanonicalResolvedTalkProviderPayload() {
val talk =
fun readsMainSessionKeyAndInterruptFlag() {
val config =
json.parseToJsonElement(
"""
{
"resolved": {
"provider": "elevenlabs",
"config": {
"voiceId": "voice-resolved"
}
"talk": {
"interruptOnSpeech": true,
"silenceTimeoutMs": 1800
},
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
"session": {
"mainKey": "voice-main"
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
}
val parsed = TalkModeGatewayConfigParser.parse(config)
@Test
fun prefersNormalizedTalkProviderPayload() {
val talk =
json.parseToJsonElement(
"""
{
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
},
"voiceId": "voice-legacy"
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
val talk =
json.parseToJsonElement(
"""
{
"provider": "acme",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
val talk =
json.parseToJsonElement(
"""
{
"providers": {
"acme": {
"voiceId": "voice-acme"
},
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
val legacyApiKey = "legacy-key" // pragma: allowlist secret
val talk =
buildJsonObject {
put("voiceId", "voice-legacy")
put("apiKey", legacyApiKey) // pragma: allowlist secret
}
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == false)
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
}
@Test
fun readsConfiguredSilenceTimeoutMs() {
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
assertEquals("voice-main", parsed.mainSessionKey)
assertEquals(true, parsed.interruptOnSpeech)
assertEquals(1800L, parsed.silenceTimeoutMs)
}
@Test

View File

@@ -1,92 +0,0 @@
package ai.openclaw.app.voice
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TalkModeVoiceResolverTest {
@Test
fun resolvesVoiceAliasCaseInsensitively() {
val resolved =
TalkModeVoiceResolver.resolveVoiceAlias(
" Clawd ",
mapOf("clawd" to "voice-123"),
)
assertEquals("voice-123", resolved)
}
@Test
fun acceptsDirectVoiceIds() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
}
@Test
fun rejectsUnknownAliases() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
assertNull(resolved)
}
@Test
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
runBlocking {
var fetchCount = 0
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = "cached-voice",
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = {
fetchCount += 1
emptyList()
},
)
assertEquals("cached-voice", resolved.voiceId)
assertEquals(0, fetchCount)
}
@Test
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertEquals("voice-1", resolved.fallbackVoiceId)
assertEquals("voice-1", resolved.defaultVoiceId)
assertEquals("voice-1", resolved.currentVoiceId)
assertEquals("First", resolved.selectedVoiceName)
}
@Test
fun preservesCurrentVoiceWhenOverrideIsActive() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = true,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertNull(resolved.currentVoiceId)
}
}

View File

@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.0.1" apply false
id("com.android.test") version "9.0.1" apply false
id("com.android.application") version "9.1.0" apply false
id("com.android.test") version "9.1.0" apply false
id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -7,7 +7,28 @@ import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab");
const releaseOutputDir = join(androidDir, "build", "release-bundles");
const releaseVariants = [
{
flavorName: "play",
gradleTask: ":app:bundlePlayRelease",
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
},
{
flavorName: "third-party",
gradleTask: ":app:bundleThirdPartyRelease",
bundlePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"thirdPartyRelease",
"app-thirdParty-release.aab",
),
},
] as const;
type VersionState = {
versionName: string;
@@ -88,6 +109,15 @@ async function verifyBundleSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed bundle missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function main() {
const buildGradleFile = Bun.file(buildGradlePath);
const originalText = await buildGradleFile.text();
@@ -102,24 +132,28 @@ async function main() {
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
await Bun.write(buildGradlePath, updatedText);
await $`mkdir -p ${releaseOutputDir}`;
try {
await $`./gradlew :app:bundleRelease`.cwd(androidDir);
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
} catch (error) {
await Bun.write(buildGradlePath, originalText);
throw error;
}
const bundleFile = Bun.file(bundlePath);
if (!(await bundleFile.exists())) {
throw new Error(`Signed bundle missing at ${bundlePath}`);
for (const variant of releaseVariants) {
const outputPath = join(
releaseOutputDir,
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
);
await copyBundle(variant.bundlePath, outputPath);
await verifyBundleSignature(outputPath);
const hash = await sha256Hex(outputPath);
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
}
await verifyBundleSignature(bundlePath);
const hash = await sha256Hex(bundlePath);
console.log(`Signed AAB: ${bundlePath}`);
console.log(`SHA-256: ${hash}`);
}
await main();

View File

@@ -0,0 +1,430 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
PACKAGE="ai.openclaw.app"
ACTIVITY=".MainActivity"
DEVICE_SERIAL=""
INSTALL_APP="1"
LAUNCH_RUNS="4"
SCREEN_LOOPS="6"
CHAT_LOOPS="8"
POLL_ATTEMPTS="40"
POLL_INTERVAL_SECONDS="0.3"
SCREEN_MODE="transition"
CHAT_MODE="session-switch"
usage() {
cat <<'EOF'
Usage:
./scripts/perf-online-benchmark.sh [options]
Measures the fully-online Android app path on a connected device/emulator.
Assumes the app can reach a live gateway and will show "Connected" in the UI.
Options:
--device <serial> adb device serial
--package <pkg> package name (default: ai.openclaw.app)
--activity <activity> launch activity (default: .MainActivity)
--skip-install skip :app:installDebug
--launch-runs <n> launch-to-connected runs (default: 4)
--screen-loops <n> screen benchmark loops (default: 6)
--chat-loops <n> chat benchmark loops (default: 8)
--screen-mode <mode> transition | scroll (default: transition)
--chat-mode <mode> session-switch | scroll (default: session-switch)
-h, --help show help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--device)
DEVICE_SERIAL="${2:-}"
shift 2
;;
--package)
PACKAGE="${2:-}"
shift 2
;;
--activity)
ACTIVITY="${2:-}"
shift 2
;;
--skip-install)
INSTALL_APP="0"
shift
;;
--launch-runs)
LAUNCH_RUNS="${2:-}"
shift 2
;;
--screen-loops)
SCREEN_LOOPS="${2:-}"
shift 2
;;
--chat-loops)
CHAT_LOOPS="${2:-}"
shift 2
;;
--screen-mode)
SCREEN_MODE="${2:-}"
shift 2
;;
--chat-mode)
CHAT_MODE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 required but missing." >&2
exit 1
fi
}
require_cmd adb
require_cmd awk
require_cmd rg
require_cmd node
adb_cmd() {
if [[ -n "$DEVICE_SERIAL" ]]; then
adb -s "$DEVICE_SERIAL" "$@"
else
adb "$@"
fi
}
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
echo "No connected Android device (adb state=device)." >&2
exit 1
fi
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
echo "Multiple adb devices found. Pass --device <serial>." >&2
adb devices -l >&2
exit 1
fi
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
exit 2
fi
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
exit 2
fi
mkdir -p "$RESULTS_DIR"
timestamp="$(date +%Y%m%d-%H%M%S)"
run_dir="$RESULTS_DIR/online-$timestamp"
mkdir -p "$run_dir"
cleanup() {
rm -f "$run_dir"/ui-*.xml
}
trap cleanup EXIT
if [[ "$INSTALL_APP" == "1" ]]; then
(
cd "$ANDROID_DIR"
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
)
fi
read -r display_width display_height <<<"$(
adb_cmd shell wm size \
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
)"
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
echo "Failed to read device display size." >&2
exit 1
fi
pct_of() {
local total="$1"
local pct="$2"
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
}
tab_connect_x="$(pct_of "$display_width" "0.11")"
tab_chat_x="$(pct_of "$display_width" "0.31")"
tab_screen_x="$(pct_of "$display_width" "0.69")"
tab_y="$(pct_of "$display_height" "0.93")"
chat_session_y="$(pct_of "$display_height" "0.13")"
chat_session_left_x="$(pct_of "$display_width" "0.16")"
chat_session_right_x="$(pct_of "$display_width" "0.85")"
center_x="$(pct_of "$display_width" "0.50")"
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
dump_ui() {
local name="$1"
local file="$run_dir/ui-$name.xml"
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
printf '%s\n' "$file"
}
ui_has() {
local pattern="$1"
local name="$2"
local file
file="$(dump_ui "$name")"
rg -q "$pattern" "$file"
}
wait_for_pattern() {
local pattern="$1"
local prefix="$2"
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
if ui_has "$pattern" "$prefix-$attempt"; then
return 0
fi
sleep "$POLL_INTERVAL_SECONDS"
done
return 1
}
ensure_connected() {
if ! wait_for_pattern 'text="Connected"' "connected"; then
echo "App never reached visible Connected state." >&2
exit 1
fi
}
ensure_screen_online() {
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 2
if ! ui_has 'android\.webkit\.WebView' "screen"; then
echo "Screen benchmark expected a live WebView." >&2
exit 1
fi
}
ensure_chat_online() {
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
sleep 2
if ! ui_has 'Type a message' "chat"; then
echo "Chat benchmark expected the live chat composer." >&2
exit 1
fi
}
capture_mem() {
local file="$1"
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
}
start_cpu_sampler() {
local file="$1"
local samples="$2"
: >"$file"
(
for _ in $(seq 1 "$samples"); do
adb_cmd shell top -b -n 1 \
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
sleep 0.5
done
) &
CPU_SAMPLER_PID="$!"
}
summarize_cpu() {
local file="$1"
local prefix="$2"
local avg max median count
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
max="$(sort -n "$file" | tail -n 1)"
median="$(
sort -n "$file" \
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
)"
count="$(wc -l <"$file" | tr -d ' ')"
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
}
summarize_mem() {
local file="$1"
local prefix="$2"
awk -v prefix="$prefix" '
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
' "$file" >>"$run_dir/summary.txt"
}
summarize_gfx() {
local file="$1"
local prefix="$2"
awk -v prefix="$prefix" '
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
/Janky frames:/ && $4 ~ /\(/ {
pct=$4
gsub(/[()%]/, "", pct)
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
}
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
' "$file" >>"$run_dir/summary.txt"
}
measure_launch() {
: >"$run_dir/launch-runs.txt"
for run in $(seq 1 "$LAUNCH_RUNS"); do
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
sleep 1
start_ms="$(node -e 'console.log(Date.now())')"
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
connected_ms="timeout"
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
if ui_has 'text="Connected"' "launch-run-$run"; then
now_ms="$(node -e 'console.log(Date.now())')"
connected_ms="$((now_ms - start_ms))"
break
fi
sleep "$POLL_INTERVAL_SECONDS"
done
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
| tee -a "$run_dir/launch-runs.txt"
done
awk -F'[ =]' '
/total_time_ms=[0-9]+/ {
value=$4
sum+=value
count+=1
if (min==0 || value<min) min=value
if (value>max) max=value
}
END {
if (count==0) exit
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
}
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
awk -F'[ =]' '
/connected_ms=[0-9]+/ {
value=$6
sum+=value
count+=1
if (min==0 || value<min) min=value
if (value>max) max=value
}
END {
if (count==0) exit
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
}
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
}
run_screen_benchmark() {
ensure_screen_online
capture_mem "$run_dir/screen-mem-before.txt"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
if [[ "$SCREEN_MODE" == "transition" ]]; then
for _ in $(seq 1 "$SCREEN_LOOPS"); do
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 1.0
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
sleep 0.8
done
else
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 1.5
for _ in $(seq 1 "$SCREEN_LOOPS"); do
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
sleep 0.35
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
sleep 0.35
done
fi
wait "$CPU_SAMPLER_PID"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
capture_mem "$run_dir/screen-mem-after.txt"
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
}
run_chat_benchmark() {
ensure_chat_online
capture_mem "$run_dir/chat-mem-before.txt"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
if [[ "$CHAT_MODE" == "session-switch" ]]; then
for _ in $(seq 1 "$CHAT_LOOPS"); do
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
sleep 0.8
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
sleep 0.8
done
else
for _ in $(seq 1 "$CHAT_LOOPS"); do
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
sleep 0.35
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
sleep 0.35
done
fi
wait "$CPU_SAMPLER_PID"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
capture_mem "$run_dir/chat-mem-after.txt"
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
}
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
ensure_connected
measure_launch
ensure_connected
run_screen_benchmark
ensure_connected
run_chat_benchmark
printf 'results_dir=%s\n' "$run_dir"
cat "$run_dir/summary.txt"

View File

@@ -174,7 +174,12 @@ final class GatewayConnectionController {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else {
self.appModel?.gatewayStatusText =
"TLS handshake failed for \(host):\(resolvedPort). "
+ "Remote gateways must use HTTPS/WSS."
return
}
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,

View File

@@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
private var authStep: some View {
Group {
Section("Authentication") {
TextField("Gateway Auth Token", text: self.$gatewayToken)
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
@@ -724,6 +724,12 @@ struct OnboardingWizardView: View {
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if self.selectedMode == .remoteDomain {
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
}
self.manualConnectButton
}
}

View File

@@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowAlwaysPatterns: [String]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
@@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
command: command,
rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
rawCommand: allowlistRawCommand,
cwd: cwd,
env: env)
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
@@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowAlwaysPatterns: allowAlwaysPatterns,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,

View File

@@ -378,7 +378,7 @@ private enum ExecHostExecutor {
let context = await self.buildContext(
request: request,
command: validatedRequest.command,
rawCommand: validatedRequest.displayCommand)
rawCommand: validatedRequest.evaluationRawCommand)
switch ExecHostRequestEvaluator.evaluate(
context: context,
@@ -476,13 +476,7 @@ private enum ExecHostExecutor {
{
guard decision == .allowAlways, context.security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in context.allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: candidate)
else {
continue
}
for pattern in context.allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
}

View File

@@ -25,8 +25,16 @@ struct ExecCommandResolution {
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand)
// Allowlist resolution must follow actual argv execution for wrappers.
// `rawCommand` is caller-supplied display text and may be canonicalized.
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
if shell.isWrapper {
// Fail closed when env modifiers precede a shell wrapper. This mirrors
// system-run binding behavior where such invocations must stay bound to
// full argv and must not be auto-allowlisted by payload-only matches.
if ExecSystemRunCommandValidator.hasEnvManipulationBeforeShellWrapper(command) {
return []
}
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
@@ -46,13 +54,52 @@ struct ExecCommandResolution {
return resolutions
}
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
guard let resolution = self.resolveForAllowlistCommand(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env)
else {
return []
}
return [resolution]
}
static func resolveAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?) -> [String]
{
var patterns: [String] = []
var seen = Set<String>()
self.collectAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
depth: 0,
patterns: &patterns,
seen: &seen)
return patterns
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
let effective = ExecEnvInvocationUnwrapper.unwrapTransparentDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveForAllowlistCommand(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
@@ -101,6 +148,115 @@ struct ExecCommandResolution {
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func collectAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
depth: Int,
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
!envUnwrapped.isEmpty
{
self.collectAllowAlwaysPatterns(
command: envUnwrapped,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
self.collectAllowAlwaysPatterns(
command: shellMultiplexer,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
return
}
for segment in segments {
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else {
continue
}
self.collectAllowAlwaysPatterns(
command: tokens,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
}
return
}
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
else {
return
}
patterns.append(pattern)
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = ExecCommandToken.basenameLower(applet)
let shellWrappers = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
guard shellWrappers.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

View File

@@ -12,14 +12,24 @@ enum ExecCommandToken {
enum ExecEnvInvocationUnwrapper {
static let maxWrapperDepth = 4
struct UnwrapResult {
let command: [String]
let usesModifiers: Bool
}
private static func isEnvAssignment(_ token: String) -> Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
}
static func unwrap(_ command: [String]) -> [String]? {
self.unwrapWithMetadata(command)?.command
}
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
var idx = 1
var expectsOptionValue = false
var usesModifiers = false
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
@@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
@@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
@@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
}
@@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
lower.hasPrefix("--ignore-signal=") ||
lower.hasPrefix("--block-signal=")
{
usesModifiers = true
idx += 1
continue
}
@@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
}
break
}
guard idx < command.count else { return nil }
return Array(command[idx...])
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
}
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
@@ -84,7 +99,56 @@ enum ExecEnvInvocationUnwrapper {
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
break
}
if unwrapped.usesModifiers {
break
}
current = unwrapped.command
depth += 1
}
return current
}
private static func unwrapTransparentEnvInvocation(_ command: [String]) -> [String]? {
var idx = 1
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
idx += 1
break
}
if token == "-" {
return nil
}
if self.isEnvAssignment(token) {
return nil
}
if token.hasPrefix("-"), token != "-" {
return nil
}
break
}
guard idx < command.count else { return nil }
return Array(command[idx...])
}
static func unwrapTransparentDispatchWrappersForResolution(_ command: [String]) -> [String] {
var current = command
var depth = 0
while depth < self.maxWrapperDepth {
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
break
}
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrapTransparentEnvInvocation(current), !unwrapped.isEmpty else {
break
}
current = unwrapped

View File

@@ -3,6 +3,7 @@ import Foundation
struct ExecHostValidatedRequest {
let command: [String]
let displayCommand: String
let evaluationRawCommand: String?
}
enum ExecHostPolicyDecision {
@@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
rawCommand: request.rawCommand)
switch validatedCommand {
case let .ok(resolved):
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
return .success(ExecHostValidatedRequest(
command: command,
displayCommand: resolved.displayCommand,
evaluationRawCommand: resolved.evaluationRawCommand))
case let .invalid(message):
return .failure(
ExecHostError(

View File

@@ -3,6 +3,7 @@ import Foundation
enum ExecSystemRunCommandValidator {
struct ResolvedCommand {
let displayCommand: String
let evaluationRawCommand: String?
}
enum ValidationResult {
@@ -52,18 +53,47 @@ enum ExecSystemRunCommandValidator {
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
let canonicalDisplay = ExecCommandFormatter.displayString(for: command)
let legacyShellDisplay: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
ExecCommandFormatter.displayString(for: command)
nil
}
if let raw = normalizedRaw, raw != inferred {
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
if let raw = normalizedRaw {
let matchesCanonical = raw == canonicalDisplay
let matchesLegacyShellText = legacyShellDisplay == raw
if !matchesCanonical, !matchesLegacyShellText {
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
}
}
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
return .ok(ResolvedCommand(
displayCommand: canonicalDisplay,
evaluationRawCommand: self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: legacyShellDisplay)))
}
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
let normalizedRaw = self.normalizeRaw(rawCommand)
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
nil
}
return self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)
}
private static func normalizeRaw(_ rawCommand: String?) -> String? {
@@ -76,6 +106,20 @@ enum ExecSystemRunCommandValidator {
return trimmed.isEmpty ? nil : trimmed
}
private static func allowlistEvaluationRawCommand(
normalizedRaw: String?,
shellIsWrapper: Bool,
previewCommand: String?) -> String?
{
guard shellIsWrapper else {
return normalizedRaw
}
guard let normalizedRaw else {
return nil
}
return normalizedRaw == previewCommand ? normalizedRaw : nil
}
private static func normalizeExecutableToken(_ token: String) -> String {
let base = ExecCommandToken.basenameLower(token)
if base.hasSuffix(".exe") {
@@ -109,7 +153,12 @@ enum ExecSystemRunCommandValidator {
idx += 1
continue
}
if token == "--" || token == "-" {
if token == "--" {
idx += 1
break
}
if token == "-" {
usesModifiers = true
idx += 1
break
}
@@ -181,7 +230,7 @@ enum ExecSystemRunCommandValidator {
return Array(argv[appletIndex...])
}
private static func hasEnvManipulationBeforeShellWrapper(
static func hasEnvManipulationBeforeShellWrapper(
_ argv: [String],
depth: Int = 0,
envManipulationSeen: Bool = false) -> Bool

View File

@@ -1,5 +1,10 @@
import Foundation
struct HostEnvOverrideDiagnostics: Equatable {
var blockedKeys: [String]
var invalidKeys: [String]
}
enum HostEnvSanitizer {
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
@@ -41,6 +46,67 @@ enum HostEnvSanitizer {
return filtered.isEmpty ? nil : filtered
}
private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return value == 95 || (65...90).contains(value) || (97...122).contains(value)
}
private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return self.isPortableHead(scalar) || (48...57).contains(value)
}
private static func normalizeOverrideKey(_ rawKey: String) -> String? {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return nil }
guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
return nil
}
for scalar in key.unicodeScalars.dropFirst() {
if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
continue
}
return nil
}
return key
}
private static func sortedUnique(_ values: [String]) -> [String] {
Array(Set(values)).sorted()
}
static func inspectOverrides(
overrides: [String: String]?,
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
{
guard let overrides else {
return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
}
var blocked: [String] = []
var invalid: [String] = []
for (rawKey, _) in overrides {
let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard let normalized = self.normalizeOverrideKey(rawKey) else {
invalid.append(candidate.isEmpty ? rawKey : candidate)
continue
}
let upper = normalized.uppercased()
if blockPathOverrides, upper == "PATH" {
blocked.append(upper)
continue
}
if self.isBlockedOverride(upper) || self.isBlocked(upper) {
blocked.append(upper)
continue
}
}
return HostEnvOverrideDiagnostics(
blockedKeys: self.sortedUnique(blocked),
invalidKeys: self.sortedUnique(invalid))
}
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
var merged: [String: String] = [:]
for (rawKey, value) in ProcessInfo.processInfo.environment {
@@ -57,8 +123,7 @@ enum HostEnvSanitizer {
guard let effectiveOverrides else { return merged }
for (rawKey, value) in effectiveOverrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
guard let key = self.normalizeOverrideKey(rawKey) else { continue }
let upper = key.uppercased()
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
// allow request-scoped PATH overrides from agents/gateways.

View File

@@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
"CURL_HOME"
"CURL_HOME",
"CLASSPATH",
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
"DENO_DIR",
"BUN_CONFIG_REGISTRY",
"LUA_PATH",
"LUA_CPATH",
"GEM_HOME",
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"XDG_CONFIG_HOME"
]
static let blockedOverridePrefixes: [String] = [

View File

@@ -465,6 +465,23 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
overrides: params.env,
blockPathOverrides: true)
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
var details: [String] = []
if !envOverrideDiagnostics.blockedKeys.isEmpty {
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
}
if !envOverrideDiagnostics.invalidKeys.isEmpty {
details.append(
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
}
return Self.errorResponse(
req,
code: .invalidRequest,
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
}
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: params.rawCommand,
@@ -507,8 +524,7 @@ actor MacNodeRuntime {
persistAllowlist: persistAllowlist,
security: evaluation.security,
agentId: evaluation.agentId,
command: command,
allowlistResolutions: evaluation.allowlistResolutions)
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
await self.emitExecEvent(
@@ -795,15 +811,11 @@ extension MacNodeRuntime {
persistAllowlist: Bool,
security: ExecSecurity,
agentId: String?,
command: [String],
allowlistResolutions: [ExecCommandResolution])
allowAlwaysPatterns: [String])
{
guard persistAllowlist, security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
continue
}
for pattern in allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}

View File

@@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
public let label: String?
public let model: String?
public let parentsessionkey: String?
public let task: String?
public let message: String?
public init(
key: String?,
agentid: String?,
label: String?,
model: String?,
parentsessionkey: String?,
task: String?,
message: String?)
{
self.key = key
self.agentid = agentid
self.label = label
self.model = model
self.parentsessionkey = parentsessionkey
self.task = task
self.message = message
}
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case label
case model
case parentsessionkey = "parentSessionKey"
case task
case message
}
}
public struct SessionsSendParams: Codable, Sendable {
public let key: String
public let message: String
public let thinking: String?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let idempotencykey: String?
public init(
key: String,
message: String,
thinking: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
idempotencykey: String?)
{
self.key = key
self.message = message
self.thinking = thinking
self.attachments = attachments
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case key
case message
case thinking
case attachments
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
}
}
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsAbortParams: Codable, Sendable {
public let key: String
public let runid: String?
public init(
key: String,
runid: String?)
{
self.key = key
self.runid = runid
}
private enum CodingKeys: String, CodingKey {
case key
case runid = "runId"
}
}
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
@@ -1894,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
}
}
public struct TalkSpeakParams: Codable, Sendable {
public let text: String
public let voiceid: String?
public let modelid: String?
public let outputformat: String?
public let speed: Double?
public let stability: Double?
public let similarity: Double?
public let style: Double?
public let speakerboost: Bool?
public let seed: Int?
public let normalize: String?
public let language: String?
public init(
text: String,
voiceid: String?,
modelid: String?,
outputformat: String?,
speed: Double?,
stability: Double?,
similarity: Double?,
style: Double?,
speakerboost: Bool?,
seed: Int?,
normalize: String?,
language: String?)
{
self.text = text
self.voiceid = voiceid
self.modelid = modelid
self.outputformat = outputformat
self.speed = speed
self.stability = stability
self.similarity = similarity
self.style = style
self.speakerboost = speakerboost
self.seed = seed
self.normalize = normalize
self.language = language
}
private enum CodingKeys: String, CodingKey {
case text
case voiceid = "voiceId"
case modelid = "modelId"
case outputformat = "outputFormat"
case speed
case stability
case similarity
case style
case speakerboost = "speakerBoost"
case seed
case normalize
case language
}
}
public struct TalkSpeakResult: Codable, Sendable {
public let audiobase64: String
public let provider: String
public let outputformat: String?
public let voicecompatible: Bool?
public let mimetype: String?
public let fileextension: String?
public init(
audiobase64: String,
provider: String,
outputformat: String?,
voicecompatible: Bool?,
mimetype: String?,
fileextension: String?)
{
self.audiobase64 = audiobase64
self.provider = provider
self.outputformat = outputformat
self.voicecompatible = voicecompatible
self.mimetype = mimetype
self.fileextension = fileextension
}
private enum CodingKeys: String, CodingKey {
case audiobase64 = "audioBase64"
case provider
case outputformat = "outputFormat"
case voicecompatible = "voiceCompatible"
case mimetype = "mimeType"
case fileextension = "fileExtension"
}
}
public struct ChannelsStatusParams: Codable, Sendable {
public let probe: Bool?
public let timeoutms: Int?

View File

@@ -45,7 +45,7 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExecutableForTests(at: scriptPath)

View File

@@ -110,6 +110,41 @@ struct ExecAllowlistTests {
#expect(resolutions[1].executableName == "touch")
}
@Test func `resolve for allowlist uses wrapper argv payload even with canonical raw command`() {
let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
let canonicalRaw = "/bin/sh -lc \"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 2)
#expect(resolutions[0].executableName == "echo")
#expect(resolutions[1].executableName == "touch")
}
@Test func `resolve for allowlist fails closed for env modified shell wrappers`() {
let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo allowlisted"]
let canonicalRaw = "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo allowlisted\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed for env dash shell wrappers`() {
let command = ["/usr/bin/env", "-", "bash", "-lc", "echo allowlisted"]
let canonicalRaw = "/usr/bin/env - bash -lc \"echo allowlisted\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist keeps quoted operators in single segment`() {
let command = ["/bin/sh", "-lc", "echo \"a && b\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
@@ -200,6 +235,16 @@ struct ExecAllowlistTests {
}
}
@Test func `resolve keeps env dash wrapper as effective executable`() {
let resolution = ExecCommandResolution.resolve(
command: ["/usr/bin/env", "-", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolution?.rawExecutable == "/usr/bin/env")
#expect(resolution?.resolvedPath == "/usr/bin/env")
#expect(resolution?.executableName == "env")
}
@Test func `resolve for allowlist treats plain sh invocation as direct exec`() {
let command = ["/bin/sh", "./script.sh"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
@@ -240,7 +285,7 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@@ -248,11 +293,11 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
#expect(resolutions[0].executableName == "touch")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@@ -260,8 +305,33 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: rawCommand,
cwd: nil,
envOverrides: ["PATH": "/usr/bin:/bin"],
agentId: nil)
#expect(evaluation.displayCommand == rawCommand)
#expect(evaluation.allowlistResolutions.count == 1)
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
}
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `match all requires every segment to match`() {

View File

@@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
try await self.withTempStateDir { _ in
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
let firstIdentity = try Self.fileIdentity(at: url)
try await Task.sleep(nanoseconds: 1_100_000_000)
_ = ExecApprovalsStore.ensureFile()
let secondWriteDate = try Self.modificationDate(at: url)
let secondIdentity = try Self.fileIdentity(at: url)
#expect(firstWriteDate == secondWriteDate)
#expect(firstIdentity == secondIdentity)
}
}
@@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private static func modificationDate(at url: URL) throws -> Date {
private static func fileIdentity(at url: URL) throws -> Int {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
struct MissingDateError: Error {}
throw MissingDateError()
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
struct MissingIdentifierError: Error {}
throw MissingIdentifierError()
}
return date
return identifier
}
}

View File

@@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
env: [:],
resolution: nil,
allowlistResolutions: [],
allowAlwaysPatterns: [],
allowlistMatches: [],
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: nil,

View File

@@ -50,6 +50,41 @@ struct ExecSystemRunCommandValidatorTests {
}
}
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
switch result {
case let .ok(resolved):
#expect(resolved.displayCommand == rawCommand)
#expect(resolved.evaluationRawCommand == nil)
case let .invalid(message):
Issue.record("unexpected invalid result: \(message)")
}
}
@Test func `env dash shell wrapper requires canonical raw command binding`() {
let command = ["/usr/bin/env", "-", "bash", "-lc", "echo hi"]
let legacy = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi")
switch legacy {
case .ok:
Issue.record("expected rawCommand mismatch for env dash prelude")
case let .invalid(message):
#expect(message.contains("rawCommand does not match command"))
}
let canonicalRaw = "/usr/bin/env - bash -lc \"echo hi\""
let canonical = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: canonicalRaw)
switch canonical {
case let .ok(resolved):
#expect(resolved.displayCommand == canonicalRaw)
case let .invalid(message):
Issue.record("unexpected invalid result for canonical raw command: \(message)")
}
}
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
let fixtureURL = try self.findContractFixtureURL()
let data = try Data(contentsOf: fixtureURL)

View File

@@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
#expect(env["OPENCLAW_TOKEN"] == "secret")
}
@Test func `inspect overrides rejects blocked and invalid keys`() {
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
"CLASSPATH": "/tmp/evil-classpath",
"BAD-KEY": "x",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
])
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
}
@Test func `sanitize accepts Windows-style override key names`() {
let env = HostEnvSanitizer.sanitize(overrides: [
"ProgramFiles(x86)": "D:\\SDKs",
"CommonProgramFiles(x86)": "D:\\Common",
])
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
}
}

View File

@@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["CLASSPATH": "/tmp/evil-classpath"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("CLASSPATH") == true)
}
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["BAD-KEY": "x"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("BAD-KEY") == true)
}
@Test func `handle invoke rejects empty system which`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemWhichParams(bins: [])

View File

@@ -289,6 +289,17 @@ public final class OpenClawChatViewModel {
stopReason: message.stopReason)
}
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
}
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil }
@@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
return String(format: "%.3f", value)
}()
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let contentFingerprint = Self.messageContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
@@ -315,6 +318,19 @@ public final class OpenClawChatViewModel {
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
}
private static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard role == "user" else { return nil }
let contentFingerprint = Self.messageContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
return nil
}
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
}
private static func reconcileMessageIDs(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
@@ -353,6 +369,75 @@ public final class OpenClawChatViewModel {
}
}
private static func reconcileRunRefreshMessages(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
{
guard !previous.isEmpty else { return incoming }
guard !incoming.isEmpty else { return previous }
func countKeys(_ keys: [String]) -> [String: Int] {
keys.reduce(into: [:]) { counts, key in
counts[key, default: 0] += 1
}
}
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
var remainingIncomingUserRefreshCounts = countKeys(
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
var lastMatchedPreviousIndex: Int?
for (index, message) in previous.enumerated() {
if let key = Self.messageIdentityKey(for: message),
incomingIdentityKeys.contains(key)
{
lastMatchedPreviousIndex = index
continue
}
if let userKey = Self.userRefreshIdentityKey(for: message),
let remaining = remainingIncomingUserRefreshCounts[userKey],
remaining > 0
{
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
lastMatchedPreviousIndex = index
}
}
let trailingUserMessages = (lastMatchedPreviousIndex != nil
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
: ArraySlice(previous))
.filter { message in
guard message.role.lowercased() == "user" else { return false }
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
if remaining > 0 {
remainingIncomingUserRefreshCounts[key] = remaining - 1
return false
}
return true
}
guard !trailingUserMessages.isEmpty else {
return reconciled
}
for message in trailingUserMessages {
guard let messageTimestamp = message.timestamp else {
reconciled.append(message)
continue
}
let insertIndex = reconciled.firstIndex { existing in
guard let existingTimestamp = existing.timestamp else { return false }
return existingTimestamp > messageTimestamp
} ?? reconciled.endIndex
reconciled.insert(message, at: insertIndex)
}
return Self.dedupeMessages(reconciled)
}
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
var result: [OpenClawChatMessage] = []
result.reserveCapacity(messages.count)
@@ -919,7 +1004,7 @@ public final class OpenClawChatViewModel {
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.reconcileMessageIDs(
self.messages = Self.reconcileRunRefreshMessages(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId

View File

@@ -513,8 +513,11 @@ public actor GatewayChannelActor {
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
let authToken =
explicitToken ??
(includeDeviceIdentity && explicitPassword == nil &&
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
// A freshly scanned setup code should force the bootstrap pairing path instead of
// silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource

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