Compare commits

..

604 Commits

Author SHA1 Message Date
Mason Huang
33985ee935 refactor: rename hourlyMessageCounts to utcQuarterHourMessageCounts 2026-04-29 14:53:25 +08:00
konanok
5a104e6f3b fix: use precise hourly message counts for Peak Error Hours
The Peak Error Hours widget displayed identical error rates for all hours
because the legacy algorithm distributed errors proportionally across
session time spans. This produced misleading data (e.g., every hour
showing 9.89%) instead of highlighting actual error-heavy periods.

Changes:
- Add SessionHourlyMessageCounts type with quarterIndex field (0-95)
  representing UTC-based 15-minute buckets for timezone-accurate
  hour conversion — covers all global UTC offsets (±15/30/45 min)
- Collect per-quarter-hour message/error counts in loadSessionCostSummary
  using getUTCHours()/getUTCMinutes() with UTC day keys (formatUtcDayKey)
- Prefer precise quarterly data in buildPeakErrorHours for both local
  and UTC views, falling back to proportional allocation for sessions
  without quarterly data
- Fix DST bug: replace single getTimezoneOffset() with
  new Date(Date.UTC(...)).getHours() for DST-aware local hour mapping
- Extract accumulateMessageCounts helper to deduplicate daily/hourly
  message count aggregation logic
- Guard hourlyMessageCounts with .length check before including in
  summary (consistent with dailyLatency/dailyModelUsage pattern)
2026-04-29 10:58:15 +08:00
Ayaan Zaidi
e12eb9acdd docs(changelog): note Ollama configure picker fix 2026-04-29 08:23:57 +05:30
Ayaan Zaidi
d8c4d7c3c1 fix(configure): show provider model picker after setup 2026-04-29 08:23:57 +05:30
Ayaan Zaidi
2613692298 fix(ollama): avoid cloud model metadata fanout 2026-04-29 08:23:57 +05:30
Peter Steinberger
7a5b419843 refactor(plugins): simplify plugin cache boundaries 2026-04-29 03:52:22 +01:00
Vincent Koc
86c5f378d6 fix(github): skip clownfish active PR label 2026-04-28 19:51:26 -07:00
Peter Steinberger
9bf50450de docs: document ClawSweeper commit reruns 2026-04-29 03:51:00 +01:00
Peter Steinberger
ba0f2e948f ci: preinstall ffmpeg for live media checks 2026-04-29 03:48:33 +01:00
Peter Steinberger
1f055d23fd refactor(test): share onboarding e2e helpers 2026-04-29 03:44:36 +01:00
Ehsan
18237bc015 docs(install): fix gog/goplaces release URLs in docker-vm-runtime example (#72154)
Merged via squash.

Prepared head SHA: 7f06b724af
Co-authored-by: Ehsan <22658149+ebarkhordar@users.noreply.github.com>
Co-authored-by: Sally O'Malley <11166065+sallyom@users.noreply.github.com>
Reviewed-by: @sallyom
2026-04-28 22:42:37 -04:00
Sliverp
e0008268ad fix(onboarding): Improve the dynamic import UX. (#73419)
* fix(onboarding): skip redundant install prompt when only one source exists

When the channel-setup flow asks 'Install <plugin>?' after the user has
already picked the channel in the previous menu, and the only real
install source available is npm (or local), the prompt degenerates into
'<that source> vs Skip'. The user already expressed intent by picking
the channel, so re-confirming adds friction without offering a
meaningful choice.

Resolve directly to the available source in that case. Keep the prompt
when both npm and local sources exist so the user can still pick which
to use, and keep it when no real source exists (the prompt then only
offers Skip, which is informative).

* fix ci

* fix ci

* fix(channel-setup): skip redundant install prompt when only one source exists

Add autoConfirmSingleSource opt-in parameter to promptInstallChoice /
ensureOnboardingPluginInstalled / ensureChannelSetupPluginInstalled.
When set and only one real install source (npm or local, not both)
exists, the 'Install <plugin>? / Skip' prompt is skipped and the
single source is used directly.

Only channel-setup.ts passes autoConfirmSingleSource: true — the user
already expressed intent by picking the channel in the previous menu,
so re-confirming adds friction without a meaningful choice. The
onboarding and quickstart entry points keep the existing prompt
behavior unchanged.

Also fix findBundledPluginSourceInMap mock type in
onboarding-plugin-install.test.ts to avoid TS2345.

* fix(tests): revert auto-confirm test expectations and fix mock leak

- Revert 'offers registry npm specs' test to expect the prompt
  (autoConfirmSingleSource not passed)
- Revert channel-setup 'does not default to bundled local path' test
  to expect the prompt
- Reset findBundledPluginSourceInMap and
  resolveBundledInstallPlanForCatalogEntry mocks after the bundled
  prompt test to prevent cross-test leakage

* fix ci

* docs(changelog): add #73419
2026-04-29 10:41:42 +08:00
Peter Steinberger
180033eeae fix(update): resume git post-update in updated process 2026-04-29 03:39:09 +01:00
Vincent Koc
43da089790 fix(update): skip disabled plugins during post-update sync (#73970)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-28 19:36:11 -07:00
Vincent Koc
c65ec4d68c fix(github): exempt clownfish PRs from active limit closure 2026-04-28 19:34:40 -07:00
Peter Steinberger
c2e3b6e6f8 fix(openai): skip malformed empty SSE frames 2026-04-29 03:28:46 +01:00
Vincent Koc
09e2cf1103 ci: right-size codeql quality runners
Run CodeQL Critical Quality on 4 vCPU Blacksmith runners.
2026-04-28 19:26:45 -07:00
Peter Steinberger
13fdeec2cc ci: disable ClawSweeper commit checks by default 2026-04-29 03:25:20 +01:00
Peter Steinberger
38e56972cd docs: document Clownfish comment commands 2026-04-29 03:24:01 +01:00
Peter Steinberger
f4c9e71e4e fix(models): guard provider policy model shape 2026-04-29 03:16:35 +01:00
Peter Steinberger
b5a90b066d refactor: reuse docker gateway e2e helpers 2026-04-29 03:15:29 +01:00
ethanclaw
492e2a3060 fix(logs): find active log file across date boundaries (#42904)
* fix(logs): find active log file across date boundaries

Fixes #42875

When gateway runs across midnight, openclaw channels logs was looking
for today's log file instead of the active one. This change makes
the CLI find the most recently modified log file as a fallback.

(cherry picked from commit fba6b88e8644365360f82802cbe25039a091409d)

* fix(channels): resolve active log file for channel logs

(cherry picked from commit ee87397a4323f04fdd37a2fc136de02e648a92d5)

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-28 19:11:14 -07:00
Peter Steinberger
a5790946f5 test(parallels): tolerate old updater stale chunk recovery 2026-04-29 03:10:44 +01:00
Vincent Koc
1e1fe80ae0 docs(changelog): note plugin lifecycle fixes 2026-04-28 19:02:45 -07:00
Bek
d6c2280aab fix(slack): normalize action thread targets (#73931) 2026-04-28 22:02:18 -04:00
Eden
bb6a15da04 fix(gateway): improve shutdown error visibility and add close timeout
Adds structured warning collection to gateway shutdown, preserves lifecycle timeout handling, and covers HTTP/WebSocket/subsystem warning paths.

Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-28 19:01:11 -07:00
Vincent Koc
df9d26eb43 fix(active-memory): narrow prompt hook timeout 2026-04-28 19:00:14 -07:00
Andrew Barnes
79159f11f6 fix: use LRU eviction for cron schedule cache
Fixes #39679
2026-04-28 18:56:19 -07:00
Peter Steinberger
610e575844 chore: add ClawSweeper agent skill 2026-04-29 02:48:40 +01:00
Vincent Koc
1f41b8b44b fix(gateway): bound default restart deferral 2026-04-28 18:42:49 -07:00
openclaw-clownfish[bot]
7e5c3753f6 fix(security): include dangerous commands in audit known commands (#73915)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-28 18:34:55 -07:00
Vincent Koc
7a88117f42 fix(qa): retry transient Telegram polling failures 2026-04-28 18:26:37 -07:00
Peter Steinberger
51119f2ef1 fix(release): ship dist import helper 2026-04-29 02:22:56 +01:00
Jari Mustonen
d8a600f2ad context-engine: pass runtime context to ContextEngineFactory (#67243)
Merged via squash.

Prepared head SHA: 9aca6a5af1
Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-28 18:21:14 -07:00
Josh Lehman
12c52963ea fix: allow cron self-removal in isolated runs (#73028) 2026-04-28 18:16:31 -07:00
hcl
46783d41e9 fix(whatsapp): gate pairing access-control on extractable inbound user content (#73797) (#73823)
Merged via squash.

Prepared head SHA: 61506e1439
Co-authored-by: hclsys <7755017+hclsys@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-28 22:09:23 -03:00
Peter Steinberger
381c2e1d1a fix(security): tighten telegram dm audit coverage 2026-04-29 02:04:20 +01:00
Peter Steinberger
a968f4f437 test(parallels): reset macos state after restore 2026-04-29 02:03:26 +01:00
Peter Steinberger
a5824b9d01 fix(ci): stabilize full release validation 2026-04-29 02:00:30 +01:00
Peter Steinberger
28ff82dcda chore: add Clownfish cloud PR skill 2026-04-29 01:40:04 +01:00
Vincent Koc
b96e7739a9 docs(install/docker): document compose config dir default fallback
For 054b2e1b7e: docs/install/docker.md "Storage and persistence" now
records that the bundled docker-compose.yml falls back to
${HOME}/.openclaw (and ${HOME}/.openclaw/workspace for the workspace
mount), or /tmp/.openclaw when HOME is also unset, when
OPENCLAW_CONFIG_DIR / OPENCLAW_WORKSPACE_DIR are not provided. That
matches the new default expressions in the compose file and prevents an
empty-source volume spec on bare environments.
2026-04-28 17:38:25 -07:00
Vincent Koc
293348b429 fix(plugins): prebuild private qa for gauntlet 2026-04-28 17:34:18 -07:00
Vincent Koc
8e5fcfff50 fix(test): stabilize core runtime infra shard 2026-04-28 17:31:35 -07:00
Peter Steinberger
7229ec5e04 fix(ci): pin release validation child ref 2026-04-29 01:30:53 +01:00
Peter Steinberger
ceeb3a7398 ci: dispatch commit reviews on main pushes 2026-04-29 01:27:45 +01:00
pashpashpash
4aa8da3756 Route sensitive group commands to the owner privately (#73872)
* fix(commands): route sensitive group approvals privately

* fix(commands): require owner private routes

* test(commands): cover owner-derived Telegram diagnostics routing
2026-04-29 09:27:18 +09:00
Peter Steinberger
e94e9347a4 fix(cli): load plugins for local JSON agent runs 2026-04-29 01:25:50 +01:00
Peter Steinberger
c24c8bab13 test(parallels): prefer arm64 mingit downloads 2026-04-29 01:25:08 +01:00
Peter Steinberger
a820a307df fix(ci): keep postinstall script self-contained 2026-04-29 01:20:37 +01:00
Peter Steinberger
45f3074ee6 fix(cli): skip plugin preload for json agent runs 2026-04-29 01:19:05 +01:00
Peter Steinberger
3286e99bc2 refactor: share docker e2e instance helpers 2026-04-29 01:17:19 +01:00
Peter Steinberger
6249c32826 ci: raise Blacksmith Docker cache caps 2026-04-29 01:14:39 +01:00
Peter Steinberger
03b1731d0f fix(ci): preserve imported dist chunks after install 2026-04-29 01:13:03 +01:00
Joe LaPenna
054b2e1b7e fix(docker): add config dir defaults to compose mounts (#64485)
Merged via squash.

Prepared head SHA: 2c5b954a2c
Reviewed-by: @sallyom
2026-04-28 20:12:24 -04:00
Vincent Koc
fd2625a162 fix(plugins): resolve ClawHub tags in prerelease CI 2026-04-28 17:04:01 -07:00
Vincent Koc
2eac4bacee test(ci): add kitchen-sink npm plugin prerelease lane 2026-04-28 17:04:01 -07:00
Peter Steinberger
0487cc59f0 fix(bonjour): suppress ciao internal cancellations
(cherry picked from commit 27599d319e)
2026-04-29 00:59:56 +01:00
Peter Steinberger
212a32648f fix(ci): speed up tarball checks and avoid CLI bootstrap 2026-04-29 00:52:45 +01:00
Peter Steinberger
5a0702ecf8 test: add reusable OpenClaw test instance helper 2026-04-29 00:50:55 +01:00
Peter Steinberger
8f4cbbbe66 perf(prompt): stabilize channel prompt suffix 2026-04-29 00:49:57 +01:00
Peter Steinberger
d3683a61c5 fix(bonjour): recover from ciao cancellation
(cherry picked from commit c34ba97262)
2026-04-29 00:49:41 +01:00
AARON AGENT
7dc0041ca9 fix(sandbox): add once option to Docker abort listener (#58277)
Merged via squash.

Prepared head SHA: 692dd15014
Reviewed-by: @sallyom
2026-04-28 19:47:52 -04:00
Patrick Erichsen
2a7ba582cb Use kitchen sink for ClawHub plugin E2E (#73821) 2026-04-28 16:44:38 -07:00
Vincent Koc
8cca1598d9 fix(plugins): satisfy gauntlet script lint 2026-04-28 16:44:13 -07:00
Vincent Koc
e4cb0f6683 fix(plugins): run gauntlet qa through source cli 2026-04-28 16:44:12 -07:00
Vincent Koc
de3f8af48e fix(plugins): allow bundled gauntlet install scan 2026-04-28 16:44:12 -07:00
Vincent Koc
dd31a27e71 fix(plugins): run gauntlet against built cli 2026-04-28 16:44:11 -07:00
Vincent Koc
8c9cac244d fix(plugins): print gauntlet failure details 2026-04-28 16:44:11 -07:00
Vincent Koc
516a91243f fix(plugins): link gauntlet lifecycle installs 2026-04-28 16:44:11 -07:00
Vincent Koc
a6dfaaeb4e test(plugins): add gateway gauntlet 2026-04-28 16:44:10 -07:00
Peter Steinberger
ef58307f84 fix(ci): keep video live helper within plugin boundary 2026-04-29 00:40:42 +01:00
Peter Steinberger
b04c9380ed fix(ci): harden full release live checks 2026-04-29 00:36:49 +01:00
pashpashpash
43fa40a35d fix(telegram): use owners for exec approvals (#73852) 2026-04-29 08:34:46 +09:00
Patrick Erichsen
a235a487d0 docs: add clawhub rescan recovery guidance (#73414)
* docs: add clawhub rescan recovery guidance

* docs: clarify clawhub rescan wording
2026-04-28 16:34:00 -07:00
imanewstudent
e2b825eba4 fix: add local build context to docker-compose (#65894)
Merged via squash.

Prepared head SHA: d8ad8d89b7
Reviewed-by: @sallyom
2026-04-28 19:29:30 -04:00
Vincent Koc
9c9dcd4d5d ci: shard agent runtime codeql quality
Add the agent runtime boundary to the CodeQL Critical Quality workflow.
2026-04-28 16:18:33 -07:00
Peter Steinberger
a0f0c964fd test(ci): tolerate live STT brand drift 2026-04-29 00:11:31 +01:00
Peter Steinberger
d86ad7a61b test(ci): accept compact codex status output 2026-04-29 00:03:09 +01:00
Joe LaPenna
a3f74410e4 build: ignore generated docker-compose.sandbox.yml (#64257) 2026-04-28 19:02:45 -04:00
Peter Steinberger
955b4df093 fix(ci): stabilize full release validation 2026-04-28 23:54:43 +01:00
jinjim
490e6d6dc5 feat(docker): add OPENCLAW_SKIP_ONBOARDING env to skip onboarding during Docker setup (#55518)
Merged via squash.

Prepared head SHA: 2744ed8b53
Co-authored-by: jinjimz <201528812+jinjimz@users.noreply.github.com>
Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com>
Reviewed-by: @sallyom
2026-04-28 18:50:51 -04:00
Peter Steinberger
bcc6a2400d fix(gateway): make handshake timeout configurable 2026-04-28 23:50:24 +01:00
Peter Steinberger
75df09b9ec perf(plugins): cache runtime mirror file decisions 2026-04-28 23:40:43 +01:00
pashpashpash
6ce1058296 Wire diagnostics through the core chat command (#72936)
* feat: wire codex diagnostics feedback

* fix: harden codex diagnostics hints

* fix: neutralize codex diagnostics output

* fix: tighten codex diagnostics safeguards

* fix: bound codex diagnostics feedback output

* fix: tighten codex diagnostics throttling

* fix: confirm codex diagnostics uploads

* docs: clarify codex diagnostics add-on

* fix: route diagnostics through core command

* fix: tighten diagnostics authorization

* fix: pin diagnostics to bundled codex command

* fix: limit owner status in plugin commands

* fix: scope diagnostics confirmations

* fix: scope codex diagnostics cooldowns

* fix: harden codex diagnostics ownership scopes

* fix: harden diagnostics command trust and display

* fix: keep diagnostics command trust internal

* fix: clarify diagnostics exec boundary

* fix: consume codex diagnostics confirmations atomically

* test: include codex diagnostics binding metadata

* test: use string codex binding timestamps

* fix: keep reserved command trust host-only

* fix: harden diagnostics trust and resume hints

* wire diagnostics through exec approval

* fix: keep diagnostics tests aligned with bundled root trust

* fix telegram diagnostics owner auth

* route trajectory exports through exec approval

* fix trajectory exec command encoding

* fix telegram group owner auth

* fix export trajectory approval hardening

* fix pairing command owner bootstrap

* fix telegram owner exec approvals

* fix: make diagnostics approval flow pasteable

* fix: route native sensitive command followups

* fix: invoke diagnostics exports with current cli

* fix: refresh exec approval protocol models

* fix: list codex diagnostics from thread bindings

* fix: fold codex diagnostics into exec approval

* fix: preserve diagnostics approval line breaks

* docs: clarify diagnostics codex workflow
2026-04-29 07:40:37 +09:00
Peter Steinberger
7e41913a20 fix(gateway): reduce TUI history startup latency 2026-04-28 23:34:59 +01:00
Peter Steinberger
f4a9d34f98 fix(model): explain rejected session overrides 2026-04-28 23:33:24 +01:00
Peter Steinberger
baeba45be9 test: speed up tts contract shard 2026-04-28 23:28:10 +01:00
Peter Steinberger
60861b3823 ci: use api key auth for Codex CLI backend smoke 2026-04-28 23:24:45 +01:00
Peter Steinberger
e583db63c6 test(ci): stabilize release validation flakes 2026-04-28 23:10:34 +01:00
Peter Steinberger
eb970bdb42 fix(tasks): repair terminal mirrored flow timestamps 2026-04-28 23:09:37 +01:00
Peter Steinberger
1184925572 fix(ci): speed up release validation live probes 2026-04-28 23:03:57 +01:00
Peter Steinberger
cc7a209982 fix: normalize QA model refs for parity gates 2026-04-28 23:01:58 +01:00
Peter Steinberger
5ef6e82685 fix(cli): skip plugin bootstrap for json gateway agents 2026-04-28 22:54:42 +01:00
Vincent Koc
e7947948b6 test(ci): add plugin prerelease suite to CI (#73741)
* test(ci): route plugin prerelease coverage to plugin shard

* test(ci): add plugin prerelease suite to CI

* fix(ci): preserve pnpm path in plugin prerelease shard

* fix(ci): avoid inheriting secrets for plugin prerelease suite
2026-04-28 14:52:03 -07:00
Peter Steinberger
69fb7455c6 fix(ci): harden full release validation monitors 2026-04-28 22:36:14 +01:00
Peter Steinberger
d9b46e0551 ci: start repo live release checks earlier 2026-04-28 22:18:41 +01:00
Peter Steinberger
25f7e062e1 fix(ci): harden cross-os release harness 2026-04-28 22:12:27 +01:00
Peter Steinberger
7b2b0d07e8 fix(ci): disable compile cache for cross-os upgrades 2026-04-28 22:02:12 +01:00
Vincent Koc
7a5638ea88 test(qa): restore GPT-5.5 scenario live metadata 2026-04-28 13:56:58 -07:00
Peter Steinberger
193c7432e3 fix(gateway): reuse paired auth for probes 2026-04-28 21:52:50 +01:00
Peter Steinberger
969cb8b4c0 ci: use standard runner for release package preparation 2026-04-28 21:51:30 +01:00
Said Urtabajev
652bde387d podman: wire OPENCLAW_INSTALL_BROWSER build-arg to setup script (#63407)
* podman: wire OPENCLAW_INSTALL_BROWSER build-arg to setup script

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

* chore: re-trigger CI

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 16:48:58 -04:00
Peter Steinberger
35059d1e3a ci: use standard runner for cross-os preparation 2026-04-28 21:47:35 +01:00
Vincent Koc
61960342b1 test(plugin): bound plugin update package smoke 2026-04-28 13:41:52 -07:00
Vincent Koc
14f140d6f0 docs(providers/bedrock): document Opus 4.7 temperature omission
For 771846c5fa: docs/providers/bedrock.md "Advanced configuration" now
includes a "Claude Opus 4.7 temperature" accordion describing that
OpenClaw automatically omits `temperature` for Opus 4.7 Bedrock refs
(foundation model ids, named profiles, application inference profiles
whose underlying model resolves to Opus 4.7, and dotted `opus-4.7`
variants with regional prefixes), since Bedrock rejects the parameter on
that model. The fix has no user-facing knob, but Opus 4.7 Bedrock users
need to know the request shape changes silently.
2026-04-28 13:39:53 -07:00
Peter Steinberger
d84ce5e419 fix(update): disable compile cache for post-update commands 2026-04-28 21:39:10 +01:00
Peter Steinberger
11d2128820 fix(ci): build complete release package artifacts 2026-04-28 21:39:10 +01:00
pashpashpash
78d51dcebe Clear Codex app-server env keys case-insensitively on Windows (#73102)
* fix(codex): clear app-server env case variants

* fix(codex): avoid repeated env clear scans
2026-04-29 05:34:14 +09:00
Vincent Koc
4509420dd4 test(qa): add gateway CPU scenario pack 2026-04-28 13:26:43 -07:00
Peter Steinberger
5e8d3130c6 fix(qa): include mention helpers in lab runtime 2026-04-28 21:23:32 +01:00
Peter Steinberger
5642653168 fix(qa): add mention helpers to lab harness 2026-04-28 21:20:53 +01:00
Peter Steinberger
da1084caf2 ci: start release checks on standard runner 2026-04-28 21:14:37 +01:00
Peter Steinberger
7ee85a1dd6 fix: align bootstrap landing check (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
7cefdd956a fix: unblock landing checks (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
18990f4fea test: avoid bundled discovery in disabled plugin test (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
b8f071a139 fix: isolate bundled plugin test roots (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
2f7c4070f4 fix: de-dupe doctor manifest repairs (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
c244ab5667 fix: unblock plugin landing checks (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Peter Steinberger
5b1202e11e fix: tighten BlueBubbles route identity hardening (#73235) (thanks @zqchris) 2026-04-28 21:06:49 +01:00
Chris Zhang
081e4be11e fix(bluebubbles): address aisle re-review on routing-guard PR
Three findings from the second pass:

1. **MEDIUM — Cross-chat short message ID guard bypassed on empty chat
   context (CWE-285).** When `requireKnownShortId=true` and `chatContext`
   was missing or `{}`, `resolveBlueBubblesMessageId` would still resolve
   the short id. Short ids are allocated from a single global counter
   across every account and chat, so an action call without a chat
   scope could silently apply to the wrong conversation. Throw "requires
   a chat scope" instead. The previous behavior was an explicit
   "fail-open" choice with a comment acknowledging the risk; the
   underlying assumption (downstream call carries chatGuid) does not
   hold for every action handler. Test rewritten to expect fail-closed.

2. **LOW — Unsanitized messageId reflected in cross-chat guard error
   (CWE-117 / CWE-200).** The thrown error embedded the raw inputId
   (and the raw chatGuid / chatIdentifier from the cached entry until
   the previous pass). Replace the inputId with a shape descriptor
   (`<short:N-digit>` or `<uuid:prefix…>`) so cross-chat errors no
   longer leak any concrete identifier. Combined with the chat
   identifier redaction in describeChatForError (already in place),
   the error is fully redacted.

3. **LOW — PII exposure via verbose logs (CWE-532).** Untrusted webhook
   identifiers (senderId / messageId / action) were already passed
   through `sanitizeForLog`, but the helper only stripped control
   characters — it did not redact secrets such as `?password=` query
   strings or `Authorization: Bearer …` headers that occasionally
   bleed into error chains. Extend `sanitizeForLog` to redact those
   patterns. All call sites benefit immediately.
2026-04-28 21:06:49 +01:00
Chris Zhang
81fd4d560a fix(bluebubbles): address aisle review on routing-guard PR
Four findings on this PR, all addressed in this commit:

1. **Cross-chat guard bypass when ctx.chatGuid present but cached lacks chatGuid**
   (CWE-697). Earlier `isCrossChatMismatch` gated chatIdentifier and chatId
   fallback comparisons on `!ctxChatGuid`, which let any non-empty
   ctx.chatGuid suppress the fallback checks when the cached entry happened
   to lack chatGuid — letting a short id from chat A be reused while acting
   in chat B. Rewrite the function so chatIdentifier/chatId comparisons
   run independently based on availability on each side, not on whether
   ctx.chatGuid happens to be present.

2. **Sensitive chat identifiers exposed via thrown cross-chat error**
   (CWE-200). `describeChatForError` interpolated raw chatGuid /
   chatIdentifier / chatId into the error message — these can leak phone
   numbers / email addresses / chat GUIDs into agent transcripts, tool
   results, remote channel deliveries, or third-party log aggregators.
   Surface only the *shape* of the chat target with `=<redacted>` values.

3. **Group reaction drop-guard bypass via whitespace chatIdentifier**.
   Earlier guard treated "" as missing but accepted " " / "\t". Trim
   chatGuid/chatIdentifier before the missing-check so a webhook sender
   supplying whitespace cannot satisfy the guard and have peerId degrade
   to the literal "group".

4. **Log injection via webhook senderId/messageId in verbose log lines**
   (CWE-117). Untrusted webhook fields were interpolated directly into
   `logVerbose` calls without sanitization, allowing log forging if a
   sender carried CR/LF/control bytes. Wrap with the existing
   `sanitizeForLog()` helper at all such sites.

Test updates: monitor-reply-cache.test.ts cross-chat error assertions
now expect `chatGuid=<redacted>` instead of raw values.
2026-04-28 21:06:49 +01:00
Chris Zhang
8fe7d495bc docs(changelog): note BlueBubbles routing-guard hardening 2026-04-28 21:06:49 +01:00
Chris Zhang
b1195c6452 fix(bluebubbles): distinguish DM vs group chat_guid in outbound session route
resolveBlueBubblesOutboundSessionRoute classified all `chat_guid:`
prefixed targets as groups:

    const isGroup =
      parsed.kind === "chat_id" ||
      parsed.kind === "chat_guid" ||
      parsed.kind === "chat_identifier";

But BlueBubbles also encodes DM chatGuids in the same `chat_guid:`
form — they look like `iMessage;-;+15551234567` (the `;-;` separator
is the DM marker; groups use `;+;`). Treating those as groups gave
the same DM two different sessionKeys depending on how the caller
addressed it:

- handle form (`bluebubbles:imessage:+15551234567`)
  → peer.kind = "direct", from = `bluebubbles:+15551234567`
- chat_guid form (`bluebubbles:chat_guid:iMessage;-;+15551234567`)
  → peer.kind = "group", from = `group:iMessage;-;+15551234567`

When a bound DM session was looked up against the second form, no
binding matched and the outbound landed in a freshly-synthesized
"group" sessionKey — a degenerate session that the next inbound
message also failed to find, surfacing the conversation in the
wrong place.

Use resolveGroupFlagFromChatGuid (already used by monitor-normalize
to read the same marker for inbound webhooks) so both directions
agree on what counts as a group. Unknown chatGuid shapes still
fall back to "group" to preserve prior behavior — we never
silently downgrade a real group to direct.

Tests: extensions/bluebubbles/src/session-route.test.ts (new)
- chat_guid `;-;` → direct
- chat_guid `;+;` → group
- chat_guid with no recognizable marker → group (back-compat)
- handle target → direct
- chat_id / chat_identifier → group (unchanged)
- DM addressed two ways converges on the same peer kind

Local patch for upstream consideration. Latent bug introduced by
0f7cd59824 (BlueBubbles: move outbound session routing behind plugin
boundary), not commonly hit because most outbound DM call sites use
the handle form, but a real foot-gun for callers that pass the
chat_guid form.
2026-04-28 21:06:49 +01:00
Chris Zhang
07089f11c7 fix(bluebubbles): drop group reactions that arrive without any chat identifier
processReaction's peerId calculation:

    const peerId = reaction.isGroup
      ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
      : reaction.senderId;

reads as "if it's a group with at least one chat hint, use that hint;
otherwise fall through to either the literal string 'group' (group case)
or the sender id (DM case)". Two failure modes hide here:

1. BlueBubbles fires a `message-reaction` event with `isGroup: true` but
   omits chatGuid AND chatId AND chatIdentifier — peerId becomes the
   literal "group" and resolveBlueBubblesConversationRoute synthesizes
   a session key unrelated to any real binding. The reaction surfaces in
   whatever session the binding fallback picks, never the right one.

2. The same payload arrives with isGroup misclassified as false (BB's
   group-flag inference relies on chatGuid, explicit isGroup, or
   participants > 2 — none of which are guaranteed for reaction events;
   monitor.webhook.test-helpers.ts even ships a default reaction fixture
   with no chatGuid and isGroup defaulted to false). peerId then becomes
   reaction.senderId and the event is enqueued into the sender's DM
   session — the group tapback shows up inside an unrelated 1:1
   transcript Chris was looking at.

Neither outcome is recoverable without a chat hint — without chatGuid,
chatId, or chatIdentifier we cannot identify which group the reaction
belongs to. Drop the event with a verbose-log and let the agent miss
that reaction rather than route it incorrectly. DM reactions (which
legitimately may arrive with no chat hint and only a sender) keep
working because the guard is gated on `reaction.isGroup === true`.

A latent risk remains: if BB ever sends an isGroup-misclassified-as-false
payload, this guard does not catch it. That would require teaching
normalize to surface group-flag confidence, which is a larger change
left for follow-up.

Tests (extensions/bluebubbles/src/monitor.test.ts):
- Group reaction with no chat identifiers → not enqueued
- Group reaction with at least one chat identifier → still enqueued
  (regression sentinel for the new guard)

Local patch for upstream consideration.
2026-04-28 21:06:49 +01:00
Chris Zhang
6ade320421 fix(bluebubbles): apply cross-chat guard to full message GUIDs as well
The cross-chat guard added in the prior commit (resolveBlueBubblesMessageId
with chatContext) only ran on numeric short ids — `if (/^\d+$/.test(trimmed))`.
Full GUID input fell through to `return trimmed` with no chat check.

Once the short-id guard started rejecting cross-chat reuses, agents would
retry the same call with the full GUID copied from history or a previous
tool result. That second attempt bypassed the guard entirely and the
group reaction landed in the DM anyway — exactly the symptom the prior
commit was meant to close.

Apply the same `isCrossChatMismatch` check to full GUID input. Cache miss
still falls through (callers may legitimately supply a fresh-from-the-wire
GUID the cache hasn't observed yet), but cache hits with a chat mismatch
throw with a remediation hint pointed at the chat target rather than at
the id format — telling an agent to "retry with the full GUID" makes no
sense when it already supplied one.

Tests (extensions/bluebubbles/src/monitor-reply-cache.test.ts):
- UUID + same chat → resolves
- UUID + different chat → throws (this is the regression)
- UUID + cache miss → passes through (preserves behavior for fresh GUIDs)
- UUID + empty chatContext → passes through (preserves prior behavior)
- UUID error message hints at the chat target, not the id format
- chatIdentifier fallback applies to UUID input too

Local patch for upstream consideration — completes the cross-chat guard
started in the prior commit so both id forms are protected symmetrically.
2026-04-28 21:06:49 +01:00
Chris Zhang
4bd3d258cd fix(bluebubbles): refuse sender-DM fallback when resolving group inbound chatGuid
When a BlueBubbles inbound webhook arrives without `chatGuid`, processMessage
falls back to `resolveChatGuidForTarget` to look it up. The previous fallback
target was:

    isGroup && (chatId || chatIdentifier)
      ? <chat_id or chat_identifier>
      : { kind: "handle", address: message.senderId }

That `else` branch quietly covered two very different cases:

1. DM with no chatGuid — resolving via sender handle is correct, the chat
   IS the conversation with that handle.
2. **Group with no chatGuid AND no chatId AND no chatIdentifier** — resolving
   via sender handle yields *that sender's DM chatGuid*, then the rest of
   processMessage uses it for ack reactions, mark-read, outbound reply cache,
   typing indicators, and outboundTarget.

Case 2 is reachable: `monitor.webhook.test-helpers.ts` ships a default
`createMessageReactionPayloadForTest` payload with no chatGuid/chatId/
chatIdentifier and `isGroup` defaulted to `false`, mirroring real BlueBubbles
reaction/tapback webhooks. When a group reaction or tapback arrives in that
shape and isGroup is later corrected to true (or the message takes the same
poisoned path), `chatGuidForActions` becomes the sender's DM chatGuid. The
poisoned chatGuid then writes the outbound reply cache (line ~1395) with the
wrong chat, defeating the cross-chat short-id guard added in
9912472289 — a later short id resolved against that cache cannot detect the
mismatch and the agent's reaction/reply silently lands in the DM.

Symptom Chris observed (recurring after 9912472289 baked): group messages
getting reacted to from the agent's side show up in a DM transcript with
that sender, attached to a message GUID the user can no longer locate in
the DM.

Extract the fallback target construction into
`buildBlueBubblesInboundChatResolveTarget` so the rule is testable in
isolation and the wrong fallback can never be reached again:

- Group inbound + chatId present → `chat_id`
- Group inbound + chatIdentifier present → `chat_identifier`
- **Group inbound + neither → return null (caller skips chatGuid-dependent actions)**
- DM inbound → `handle` (unchanged: the conversation IS that sender)

processMessage now logs at verbose when the group case returns null instead
of silently degrading to the sender's DM.

Tests: extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts
covers the eight branches (group with id, group with identifier, group
preferring id, group with neither, blank/non-finite/null variants, DM, DM
with chat_id present, DM with empty sender).

Local patch for upstream consideration — pairs with the short-id chat guard
landed in the previous commit.
2026-04-28 21:06:49 +01:00
Chris Zhang
9f97e8c521 fix(bluebubbles): scope short message id resolution to the caller's chat
BlueBubbles short message ids (numeric aliases like "1", "5" that agents
use instead of full GUIDs to save tokens) are allocated from a single
global counter across every account and every chat. Nothing in
resolveBlueBubblesMessageId verified that the resolved GUID was actually
in the chat the caller was acting on, so any time an agent reused or
mis-remembered a short id — especially common after a long group
conversation — the id could silently point at a different chat entirely.

Symptom Chris observed: reactions/tapbacks and quoted replies authored
inside a group would intermittently land in a DM, targeting an old
message the user could no longer see. Tool call looks successful, chat
archive shows a group reaction appearing in the DM transcript.

Add an optional chatContext parameter to resolveBlueBubblesMessageId
(chatGuid / chatIdentifier / chatId). When provided, look up the
cached reply entry for the resolved GUID and compare. A clear mismatch
(same identifier present on both sides, different values) throws with a
message that lists both chats and points at "use the full GUID", so the
agent fails fast and retries with a disambiguated id. Ambiguous cases
(either side missing all identifiers) pass through to preserve existing
behavior for callers that cannot supply chat hints. The comparison
mirrors resolveReplyContextFromCache so outbound and inbound paths agree
on scope.

Update every call site that resolves a short id for outbound BB traffic
to pass chatContext:
- extensions/bluebubbles/src/actions.ts: react, edit, unsend, reply
  (build context from chat* params, then to/target, then the tool's
  currentChannelId)
- extensions/bluebubbles/src/channel.ts sendText: derive context from
  the `to` target
- extensions/bluebubbles/src/media-send.ts: same
- extensions/bluebubbles/src/monitor-processing.ts deliver path: pass
  the chat already resolved for routing

Add buildBlueBubblesChatContextFromTarget to targets.ts so callers can
project a raw target string (`chat_guid:...`, `chat_id:42`,
`imessage:+1...`, bare handle) into the context shape.

Tests:
- extensions/bluebubbles/src/monitor-reply-cache.test.ts (new, 8 cases):
  same-chat resolves, cross-chatGuid throws, ambiguous passes,
  chatIdentifier fallback, chatId fallback, full GUID input bypasses,
  error message identifies both chats, unknown short id still errors.
- extensions/bluebubbles/src/actions.test.ts: update the react short-id
  assertion to verify chatContext now flows through.

Local patch for upstream consideration — same root cause affects every
BB user; plan is to open a separate upstream PR once this bakes locally.
2026-04-28 21:06:49 +01:00
Peter Steinberger
96a21e2553 fix(qa): restore release channel reply checks 2026-04-28 21:05:35 +01:00
Peter Steinberger
3aac8e650c fix(googlechat): keep config schema on runtime api 2026-04-28 21:04:44 +01:00
Peter Steinberger
5dfc14d49b fix(tasks): close stale terminal acp sessions 2026-04-28 21:03:55 +01:00
Peter Steinberger
3cad579c4e fix(plugin-sdk): restore discord compatibility facade 2026-04-28 20:59:26 +01:00
Peter Steinberger
d1a7612bd6 docs(changelog): narrow gateway status fix reference 2026-04-28 20:58:09 +01:00
Peter Steinberger
c399fb750b fix(ui): handle Google Live binary talk frames 2026-04-28 20:57:46 +01:00
Peter Steinberger
0a2d635e68 fix(gateway): harden local reachability checks
Co-authored-by: arthurianresolve <arthurianresolve@users.noreply.github.com>
Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com>
2026-04-28 20:57:14 +01:00
Peter Steinberger
3d736f67cf test: fix onboard Docker test state setup 2026-04-28 20:56:19 +01:00
Peter Steinberger
c1c217035d test: align bare reset bootstrap expectation 2026-04-28 20:56:04 +01:00
Peter Steinberger
3b593bc561 fix(cli): authorize gateway model probe overrides 2026-04-28 20:55:44 +01:00
Vincent Koc
87172dc9fe fix(ci): harden package acceptance refs 2026-04-28 12:53:05 -07:00
Peter Steinberger
f0c8640d81 test: speed up read-only channel fixtures 2026-04-28 20:49:55 +01:00
Peter Steinberger
0dcab4e347 fix(agents): harden bootstrap and ACP session routing 2026-04-28 20:47:34 +01:00
Vincent Koc
3ae69498e2 ci: shard channel codeql security
Add a narrow channel-runtime CodeQL critical-security shard and document it.
2026-04-28 12:46:44 -07:00
Peter Steinberger
230f8886c6 ci: keep full release validation children pinned 2026-04-28 20:43:39 +01:00
HeYan
170a961744 fix(config): guard non-string values in env.vars to prevent TypeError (#42402)
* fix(config): guard non-string values in env.vars to prevent TypeError (#42363)

* docs(changelog): note malformed env vars crash fix

---------

Co-authored-by: Altay <altay@uinaf.dev>
2026-04-28 22:43:22 +03:00
Peter Steinberger
0f3a9d812b docs(changelog): note model auth fixes 2026-04-28 20:40:11 +01:00
Peter Steinberger
771846c5fa fix(bedrock): omit Opus temperature for profiles 2026-04-28 20:39:58 +01:00
Peter Steinberger
1f26e32f5f fix(agents): strip empty assistant transcript text 2026-04-28 20:39:58 +01:00
Peter Steinberger
1824ceba54 fix(agents): reuse cached Claude keychain credentials 2026-04-28 20:39:58 +01:00
Peter Steinberger
aec5efed8d fix(agents): resolve model aliases before fallback 2026-04-28 20:39:58 +01:00
Peter Steinberger
06a0cd88fb fix(discord): align gateway metadata timeout tests 2026-04-28 20:39:28 +01:00
Peter Steinberger
0608c1015b perf(plugins): cache manifest metadata loads 2026-04-28 20:39:28 +01:00
Vincent Koc
98f5fd12df docs(gateway/security): list system-reminder and previous_response in outbound stripping
For c2d31a5e59: docs/gateway/security/index.md "External content
special-token sanitization" section already mentions the outbound
sanitizer with `<tool_call>` and `<function_calls>` examples, but it
predates the new internal-runtime-scaffolding stripping that targets
`<system-reminder>` and `<previous_response>` tags. Adds those two tags
as explicit examples and notes the final channel delivery boundary so
operators reading the security page see the same coverage exposed by
the c2d31a5e59 sanitizer.
2026-04-28 12:39:15 -07:00
Peter Steinberger
c500e8704f fix(gateway): recover stale session lanes 2026-04-28 20:37:29 +01:00
Peter Steinberger
933c7968dc fix(ci): stabilize full release validation lanes 2026-04-28 20:36:42 +01:00
Peter Steinberger
1e9faa2a59 docs: document inter-session prompt guards 2026-04-28 20:34:55 +01:00
Peter Steinberger
c2d31a5e59 fix(outbound): strip internal runtime scaffolding 2026-04-28 20:34:55 +01:00
Peter Steinberger
c5c08c074a fix(agents): mark inter-session prompts 2026-04-28 20:34:54 +01:00
Peter Steinberger
5de06ac00e test: keep bundled root fixtures scoped 2026-04-28 20:28:45 +01:00
Peter Steinberger
cb8c513ce3 fix(telegram): honor final-only streaming mode 2026-04-28 20:28:06 +01:00
Vincent Koc
df8611c420 test(loader): re-enable bundled fixtures 2026-04-28 12:24:28 -07:00
Vincent Koc
b014462690 fix(test): trust bundled plugin fixtures explicitly 2026-04-28 12:24:28 -07:00
Peter Steinberger
0311e172e0 test: preserve bundled dir fixture helpers 2026-04-28 20:19:51 +01:00
Peter Steinberger
c89b67e6c8 test(config): isolate bundled channel metadata fixture 2026-04-28 20:17:51 +01:00
Peter Steinberger
9f37ff0c6c test: allow bundled root fixtures under vitest 2026-04-28 20:14:56 +01:00
Peter Steinberger
e61756f9e8 test(plugin-sdk): avoid heavy facade fallback fixture 2026-04-28 20:14:14 +01:00
Peter Steinberger
df4e2ecb87 fix(plugin-sdk): expose concrete memory host types 2026-04-28 20:14:14 +01:00
Peter Steinberger
4a24b23e3e fix(ci): stabilize full release validation 2026-04-28 20:14:14 +01:00
Peter Steinberger
f641691910 fix(discord): harden account and binding routing 2026-04-28 20:08:27 +01:00
Vincent Koc
87fd216d9a chore(plugin-sdk): refresh api baseline 2026-04-28 12:06:27 -07:00
Peter Steinberger
702e5fc4a9 test: isolate facade bundled fixture roots 2026-04-28 20:04:06 +01:00
Peter Steinberger
6d4599a796 fix: satisfy discord gateway lint 2026-04-28 19:54:52 +01:00
Peter Steinberger
f2f34e5f35 fix: restore ci gates on main 2026-04-28 19:54:52 +01:00
Vincent Koc
bb0461b682 ci: shard channel codeql quality
Add a narrow channel-runtime CodeQL critical-quality shard and document it.
2026-04-28 11:52:54 -07:00
Peter Steinberger
6d542ebcee test: clean up Docker test-state leftovers 2026-04-28 19:50:51 +01:00
Peter Steinberger
d22a851253 test: reuse Docker test-state in core E2E lanes 2026-04-28 19:47:11 +01:00
Peter Steinberger
4b69dc6228 docs(changelog): note discord gateway fixes 2026-04-28 19:40:06 +01:00
Peter Steinberger
7191f1a1eb fix(discord): tune gateway intents and metadata timeout 2026-04-28 19:39:49 +01:00
Peter Steinberger
065284deab fix(auto-reply): pass model catalog to think menus 2026-04-28 19:37:10 +01:00
Kevin Lin
f351961173 fix: log fetch timeout aborts (#73692)
* fix: log fetch timeout aborts

* fix: redact relative timeout urls
2026-04-28 11:36:10 -07:00
Vincent Koc
dcd665cd05 fix(nvidia): align NIM provider metadata
Persist the NVIDIA_API_KEY marker in generated catalog output and mark bundled NVIDIA Chat Completions models as string-content compatible.\n\nFixes #73013.\nFixes #50107.\nRefs #73014.
2026-04-28 11:30:57 -07:00
Peter Steinberger
e2295b33c1 fix(ci): restore full release validation blockers 2026-04-28 19:20:18 +01:00
Peter Steinberger
2290adbf57 test: reuse Docker test-state in more lanes 2026-04-28 19:19:53 +01:00
Vincent Koc
e476523082 ci: shard gateway codeql quality
Add a narrow gateway/runtime CodeQL critical-quality shard and document it.
2026-04-28 11:16:48 -07:00
Peter Steinberger
cd2e13be8a test: isolate channel catalog fixtures 2026-04-28 19:06:38 +01:00
Peter Steinberger
84154bb09c perf(test): speed up boundary report checks 2026-04-28 19:00:22 +01:00
Peter Steinberger
53d34e7cde fix(cli): support image files in model probes 2026-04-28 18:52:15 +01:00
Peter Steinberger
3f780bb27d test: share Docker test-state wrapper 2026-04-28 18:47:45 +01:00
Vincent Koc
4d82dc4fb4 docs(skills): expand test performance workflow 2026-04-28 10:41:53 -07:00
Vincent Koc
6d323ee736 docs(channels/groups): note native command bypass of visibleReplies
For 195f704c74: docs/channels/groups.md "Visible replies" section now
records that native slash commands (Discord, Telegram, and other surfaces
with native command support) reply visibly even when
`messages.groupChat.visibleReplies` is `"message_tool"`, so the channel-
native command UI gets the response it expects. Text-typed `/...` commands
and ordinary chat turns still follow the configured group default.
2026-04-28 10:24:14 -07:00
Vincent Koc
7d2d8732d0 docs(plugins/hooks): document per-hook timeoutMs registration option
For 891c7d9f1c: docs/plugins/hooks.md "Quick start" now lists the `priority`
and new `timeoutMs` opts that `api.on(...)` accepts, explaining that the
per-hook budget aborts a slow handler instead of letting plugin setup or
recall work consume the caller's configured model timeout. The change is
traceable to the new `OpenClawPluginApi.on` `{ priority?; timeoutMs? }`
signature and `PluginHookRegistration.timeoutMs` field added in the same
SHA.
2026-04-28 10:12:44 -07:00
Shakker
c0ec58f4b6 fix: preserve runtime kind install fallback 2026-04-28 18:04:54 +01:00
Shakker
a48ffda7f7 chore: trace plugin lifecycle phases 2026-04-28 18:03:01 +01:00
Shakker
3d89b0f2ec fix: use plugin metadata for install slots 2026-04-28 18:02:40 +01:00
Neerav Makwana
3de5476f51 fix(auto-reply): preserve DM continuity across silent session rotations (#70898)
Merged via squash.

Prepared head SHA: 13bd2cef86
Co-authored-by: neeravmakwana <261249544+neeravmakwana@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-28 10:01:15 -07:00
Ayaan Zaidi
7120f5b254 docs(changelog): note native command group reply fix 2026-04-28 22:11:27 +05:30
Ayaan Zaidi
8af50b5b4c fix(commands): preserve owner allowlists for native auth 2026-04-28 22:11:27 +05:30
Ayaan Zaidi
195f704c74 fix(reply): keep native command replies visible 2026-04-28 22:11:27 +05:30
Ayaan Zaidi
7b91f06384 fix(commands): honor channel-native command auth 2026-04-28 22:11:27 +05:30
Pavan Kumar Gondhi
bdfb408ce6 fix(plugins): restrict bundled plugin dir resolution to trusted package roots (#73275)
* fix: address issue

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address codex review feedback

* fix: address codex review feedback

* fix: address codex review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* docs: add changelog entry for PR merge
2026-04-28 21:35:32 +05:30
Pavan Kumar Gondhi
230f7122dd fix(security): prevent workspace PATH injection via service env and trash helpers (#73264)
* fix: address issue

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address build feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-28 21:30:51 +05:30
Ayaan Zaidi
b79e617ad1 fix: persist Telegram native command metadata (#57548) (thanks @GaosCode) 2026-04-28 21:18:58 +05:30
Ayaan Zaidi
c57960b8d1 fix(telegram): distill native metadata session key 2026-04-28 21:18:58 +05:30
MrBrain
c4f741e534 fix(telegram): persist native command metadata to target sessions 2026-04-28 21:18:58 +05:30
Harry Xie
891c7d9f1c fix(active-memory): align recall timeout with hook runner
Fixes #72606.
2026-04-28 10:15:01 -05:00
Tak Hoffman
f256eeba43 fix(active-memory): use bundled recall tool
Fixes #73502.

Active Memory now allows its hidden recall sub-agent to use both bundled memory tool contracts: memory_recall for memory-lancedb and memory_search/memory_get for memory-core. The prompt prefers memory_recall when available and falls back to the legacy tool pair when that is the active backend surface.

Also updates Active Memory docs, QA mock fixtures, and debug parsing compatibility for the two recall paths.
2026-04-28 09:03:47 -05:00
Radek Sienkiewicz
dd643c82b5 fix(whatsapp): expose Baileys socket timing (#73580)
Merged via squash.

Prepared head SHA: d34755262f
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-04-28 15:46:47 +02:00
Joseph Krug
16906780fd feat(active-memory): return partial transcript on timeout (openclaw#73219)
Verified:
- pnpm test extensions/active-memory/index.test.ts
- pnpm exec oxfmt --check --threads=1 extensions/active-memory/index.ts extensions/active-memory/index.test.ts CHANGELOG.md
- git diff --check

Co-authored-by: joeykrug <5925937+joeykrug@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:44:46 -05:00
Lidang Jiang
6d539db011 fix: support explicit active-memory chat types (openclaw#66285)
Verified:
- pnpm install --frozen-lockfile
- pnpm test extensions/active-memory/config.test.ts extensions/active-memory/index.test.ts
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md extensions/active-memory/index.ts extensions/active-memory/index.test.ts extensions/active-memory/config.test.ts extensions/active-memory/openclaw.plugin.json
- git diff --check

Co-authored-by: Lidang-Jiang <119769478+Lidang-Jiang@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:43:06 -05:00
Peter
ba17b8b728 docs(active-memory): document cacheTtlMs bounds (#65708) (openclaw#65737)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test (local full suite failed in unrelated plugin/logging shards; PR-specific docs/changelog checks and GitHub checks passed)
- GitHub status checks for c2c5a94df8 completed without failure

Co-authored-by: WuKongAI-CMU <210765158+WuKongAI-CMU@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:42:16 -05:00
quengh
373e7fc242 feat(active-memory): add allowedChatIds/deniedChatIds per-conversation filters (openclaw#67977)
Verified:
- pnpm install --frozen-lockfile
- git diff --check
- pnpm exec oxfmt --check --threads=1 extensions/active-memory/index.ts extensions/active-memory/index.test.ts docs/concepts/active-memory.md CHANGELOG.md
- OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1 OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=.vitest-cache-pr67977 pnpm test extensions/active-memory/index.test.ts extensions/active-memory/config.test.ts
- gh pr checks 67977 --repo openclaw/openclaw --required

Co-authored-by: quengh <3940773+quengh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:37:55 -05:00
Spolen23
12aaef9035 Fix infer CLI reliability gaps (openclaw#63263)
Verified:
- pnpm install --frozen-lockfile
- git diff --check
- pnpm test src/media-understanding/defaults.test.ts src/media-understanding/runner.vision-skip.test.ts src/media-understanding/runner.cli-audio.test.ts src/web-search/runtime.test.ts
- pnpm tsgo:test:src

Co-authored-by: Spolen23 <215900770+Spolen23@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:36:41 -05:00
SimbaKingjoe
bdb75bd8c7 fix(active-memory): skip payload-less memory_search toolResults in tr… (openclaw#68773)
Verified:
- pnpm install --frozen-lockfile
- pnpm test extensions/active-memory/index.test.ts
- pnpm exec oxfmt --check --threads=1 extensions/active-memory/index.ts extensions/active-memory/index.test.ts CHANGELOG.md
- git diff --check origin/main..HEAD
- gh pr checks 68773 --repo openclaw/openclaw --required

Co-authored-by: SimbaKingjoe <126222269+SimbaKingjoe@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-04-28 08:20:13 -05:00
Pavan Kumar Gondhi
189c91eae6 fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI] (#72925)
* fix: address issue

* docs: add changelog entry for PR merge
2026-04-28 18:31:05 +05:30
Pavan Kumar Gondhi
037f197684 fix(agents): canonicalize provider aliases in byProvider tool policy lookup [AI] (#72917)
* fix: address issue

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-28 18:14:59 +05:30
Pavan Kumar Gondhi
ccb3af556f fix(security): block npm_execpath injection from workspace .env [AI-assisted] (#73262)
* fix: address issue

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-28 18:11:16 +05:30
Alex Knight
7a23c18830 fix(acpx): validate runtime session mode at wrapper boundary (#73071) (#73548) 2026-04-28 22:35:25 +10:00
Alex Knight
7a23b2d945 fix: decode web fetch legacy charsets (#73513)
* fix: decode web fetch legacy charsets
2026-04-28 22:09:06 +10:00
Alex Knight
e4ff7c1620 fix: Discord read/search timeout, session-key fallback, and gateway execution mode (#73521)
* fix: Discord read/search timeout, session-key fallback, and gateway execution mode

- Add 15s timeout to readMessagesDiscord and searchMessagesDiscord so they
  fail fast instead of hanging indefinitely (#73431)
- Fall back to CommandTargetSessionKey in dispatchReplyFromConfig when
  SessionKey is empty, so Discord inbound message:received hooks fire
  reliably (#73431, refs #33038)
- Add resolveExecutionMode to Discord channel actions routing read/search
  through gateway timeout path, matching Telegram's pattern (#73431)

* fix: move timeout to fetch layer, drop send.messages wrapper

Inject AbortSignal.timeout into the Discord proxy-request-client fetch
wrapper so every Discord REST call gets a 15s timeout at the HTTP level.
This replaces the Promise.race wrapper in send.messages.ts — cleaner,
covers all calls, and actually aborts the TCP connection.

* fix: remove unused callerController variable in proxy-request-client test

* fix: remove unnecessary mergeAbortSignal helper
2026-04-28 21:46:05 +10:00
Vincent Koc
c478aeca5a docs: cover cron_changed plugin hook and legacy env-var deprecation
- docs/plugins/hooks.md: add `cron_changed` to the Lifecycle hook catalog and
  a Gateway lifecycle paragraph describing its typed event payload, run
  status, delivery status, and removed-event job snapshot, so plugin authors
  picking up f155a5f955 (#72773) have a canonical reference beyond the
  sdk-overview bullet that already shipped in the same SHA.
- docs/help/environment.md: add a "Legacy environment variables" section for
  aa1834a3ff so users see that `CLAWDBOT_*` and `MOLTBOT_*` prefixes are now
  ignored and trigger an `OPENCLAW_LEGACY_ENV_VARS` deprecation warning,
  with a rename example to `OPENCLAW_*`.
2026-04-28 04:40:38 -07:00
Alex Knight
f155a5f955 Add cron changed plugin hook (#72773)
* feat: add cron changed plugin hook

* fix: improve cron_changed hook correctness and code quality

- Fix PluginHookGatewayCronDeliveryStatus: replace 'error' with 'unknown'
  to match internal CronDeliveryStatus enum
- Add job snapshot to CronEvent so removed events carry the deleted job
- Extract pickDefined helper, replace 14-field verbose spread mapping
- Add toPluginCronJob mapper for explicit internal→public type boundary
- Fix schedule union: use literal-only kind discriminants for TS narrowing
- Use loadConfig() (runtime) instead of params.cfg (startup) in hook ctx
- Use formatErrorMessage instead of String(err) for stack preservation
- Fix pre-existing getCron TS2322 with explicit cast (matches gateway_start)
- Re-export supporting types from hooks.ts for plugin consumers
- Add tests: removed events with job, finished with full fields, runtime cfg
2026-04-28 21:34:42 +10:00
Alex Knight
e84ebeafbd fix(memory-core): retry dreaming cron startup reconciliation (#73493)
Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-04-28 21:15:23 +10:00
Peter Steinberger
2ccdbc7dd9 fix(plugin-sdk): keep memory host wildcard shims 2026-04-28 12:08:13 +01:00
Peter Steinberger
343c69d7a1 fix: auto-enable media provider plugins 2026-04-28 12:05:30 +01:00
Peter Steinberger
3eb2a9d371 fix(plugin-sdk): drop unavailable memory host exports 2026-04-28 12:01:43 +01:00
Vincent Koc
e10f493160 ci: shard config codeql quality
Split config quality CodeQL results into a separate category while keeping the default quality bucket narrow.
2026-04-28 04:00:14 -07:00
Vincent Koc
75ba8398f9 fix(gateway): expose event loop health in readiness 2026-04-28 03:56:58 -07:00
Peter Steinberger
9f7932fbcc test: update gateway client callsite guard 2026-04-28 11:54:43 +01:00
Peter Steinberger
9e5aa10e97 fix(memory-host): preserve core resolver exports in sdk shims 2026-04-28 11:54:12 +01:00
Peter Steinberger
af10be59d8 fix(approvals): stop stale approval resume loops 2026-04-28 11:53:22 +01:00
Peter Steinberger
2a0af6754e ci: narrow ClawSweeper dispatch cancellation 2026-04-28 11:53:06 +01:00
Peter Steinberger
ba722fd126 test: speed up channel mcp tests 2026-04-28 11:49:18 +01:00
Peter Steinberger
8260b64f7a fix(memory-host): keep sdk shim exports complete 2026-04-28 11:48:59 +01:00
loongfay
7b07a0ab8f feat(channel) add yuanbao docs entrance (#73443)
* feat(channel) add yuanbao docs entrance

* feat(channel): add yuanbao docs entrance (#73443) (thanks @loongfay)

---------

Co-authored-by: loongzhao <loongzhao@tencent.com>
Co-authored-by: sliverp <870080352@qq.com>
2026-04-28 18:47:09 +08:00
Vincent Koc
d55c7ea997 fix(plugins): bound prompt memory recall latency 2026-04-28 03:46:18 -07:00
Peter Steinberger
5de284c2e3 fix(release): restore main release checks 2026-04-28 11:44:44 +01:00
Peter Steinberger
dc541662f8 docs(changelog): finalize 2026.4.27 notes 2026-04-28 11:41:29 +01:00
Vincent Koc
3c0eac31f1 docs(providers/qwen): note explicit qwen3.6-plus opt-in on Coding Plan
For 058b57867e: docs/providers/qwen.md "Qwen 3.6 Plus availability"
accordion now records that the bundled catalog still does not advertise
`qwen3.6-plus` on Coding Plan endpoints, but explicitly configured
`models.providers.qwen.models` entries for that model are honored on
Coding Plan baseUrls so subscribers whose plan enables it can opt in. The
upstream API still decides whether the call succeeds.
2026-04-28 03:40:39 -07:00
Peter Steinberger
adf166936a docs(changelog): document pairing and approval fixes 2026-04-28 11:38:18 +01:00
Peter Steinberger
6559288d4a fix(agents): hide successful resume fallback prefix 2026-04-28 11:38:18 +01:00
Peter Steinberger
6dec2e1852 fix(telegram): scope native approvals by target account 2026-04-28 11:38:18 +01:00
Peter Steinberger
279e6453fc fix(gateway): make repeated approval resolves idempotent 2026-04-28 11:38:18 +01:00
Peter Steinberger
885806d5ca fix(gateway): stop stale device token reconnect loops 2026-04-28 11:38:18 +01:00
Peter Steinberger
205d8d4994 fix(pairing): recover malformed pairing state files 2026-04-28 11:38:18 +01:00
Vincent Koc
aa1834a3ff fix(gateway): warn on legacy env vars
Fixes #53482.

Supersedes #53667.
2026-04-28 03:37:57 -07:00
Peter Steinberger
d770a3b786 test(memory): stabilize reindex and cron checks 2026-04-28 11:36:28 +01:00
Peter Steinberger
6a387afc53 refactor(memory-host): route sdk shims to package source 2026-04-28 11:36:28 +01:00
Peter Steinberger
94fc91e235 ci: harden clawsweeper dispatch workflow 2026-04-28 11:35:40 +01:00
Peter Steinberger
5a1ff1347d fix(slack): bound inbound media downloads 2026-04-28 11:35:26 +01:00
James Reagan
a722da3ed0 fix(gateway): align session thinking defaults (#63418)
Aligns Gateway history and session list thinking-default resolution so backend session state matches the Control UI default label:

- `chat.history` now falls back through the shared Gateway session thinking-default resolver.
- Explicit session overrides still win, then owning `agents.list[].thinkingDefault`, then global/model/catalog defaults.
- `sessions.list` catalog-aware thinking defaults are covered by focused regressions.

PR by @jpreagan.

Validated in Blacksmith Testbox `tbx_01kq9t1aeqrz1mj598vvqv9dpg`:
- `pnpm test:serial src/gateway/session-utils.test.ts src/gateway/server.sessions.gateway-server-sessions-a.test.ts src/gateway/server.chat.gateway-server-chat.test.ts` (141 passed)
- `OPENCLAW_TESTBOX=1 pnpm check:changed`
2026-04-28 03:34:58 -07:00
Vincent Koc
d70191f8af feat(sandbox): add Docker GPU passthrough
Add opt-in `sandbox.docker.gpus` config plumbing for Docker sandbox containers.

- thread the optional GPU passthrough field through config types, schema, resolution, and Docker create args
- reject empty config values and emit `--gpus` as a separate Docker argv pair
- document the Docker-only behavior and credit the original contributor in the changelog

Fixes #57976.
Carries forward #58124 from @cyan-ember.

Co-authored-by: cyan-ember <5855097+cyan-ember@users.noreply.github.com>
2026-04-28 03:33:28 -07:00
Peter Steinberger
7150acba69 ci: debounce clawsweeper dispatch metadata 2026-04-28 11:31:49 +01:00
Peter Steinberger
35bc13f9ef fix: prefer OpenAI media for Codex defaults 2026-04-28 11:30:17 +01:00
Shakker
32c987626b fix: prune stale plugin runtime mirror entries 2026-04-28 11:25:09 +01:00
Shakker
92016b82ae fix: refresh plugin runtime mirrors in place 2026-04-28 11:25:09 +01:00
Shakker
7727e102a5 fix: scope plugin inspect runtime loading 2026-04-28 11:25:09 +01:00
Shakker
1bd4b7ac4d fix: keep plugin uninstall on metadata path 2026-04-28 11:25:09 +01:00
Vincent Koc
7950a18025 fix(whatsapp): recover stale listener after auth conflict churn (#72621)
* fix(whatsapp): recover stale listener after auth conflict churn

* fix(whatsapp): block symlink auth cleanup escapes

* fix(whatsapp): refuse external auth cleanup
2026-04-28 03:24:57 -07:00
Vincent Koc
e2f3044b8f fix(memory-wiki): route bridge CLI through gateway
Route Memory Wiki bridge-mode status, doctor, and bridge import CLI paths through Gateway RPC when bridge artifact reads are active, while preserving local/offline fallbacks.

Harden Gateway CLI rendering and imported-source writes: validate RPC response shapes, bound response strings before rendering/JSON serialization, sanitize/escape terminal-controlled output, avoid redundant JSON forwarding, and replace imported source pages through a temp-file rename path with symlink and hardlink regressions.

Fixes #65722
Fixes #65976
Fixes #66082
Fixes #67979
Fixes #68371
Fixes #68828
Fixes #69019
Fixes #70181
Fixes #70242
Fixes #70842

Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
2026-04-28 03:22:12 -07:00
Vincent Koc
f12dedb5c8 fix(tasks): keep media tool runs live 2026-04-28 03:21:00 -07:00
Peter Steinberger
1b13f53047 fix(ollama): reject garbled Kimi symbol output 2026-04-28 11:20:15 +01:00
Vincent Koc
77192572f6 ci: split macos codeql shard
Split the slow macOS CodeQL job into its own weekly/manual workflow and keep the daily CodeQL default on the fast JS/Actions security path.
2026-04-28 03:14:07 -07:00
Peter Steinberger
6cc6996a1c fix(slack): tune socket mode pong timeout 2026-04-28 11:13:03 +01:00
Peter Steinberger
c9ead1b928 test: annotate Docker test-state scenarios 2026-04-28 11:10:30 +01:00
Peter Steinberger
ade9aaae89 fix(cli): classify scope-limited status probes as reachable 2026-04-28 11:09:42 +01:00
Peter Steinberger
1fcf0a422f fix(agents): keep media generation tasks fresh 2026-04-28 10:59:42 +01:00
Peter Steinberger
9da76c4255 test: fix openclaw test state helper types 2026-04-28 10:59:42 +01:00
Gabriel Kripalani
17ef9ef895 feat(openrouter): add video generation provider (#72700)
Adds OpenRouter video generation via video_generate, with hardened async polling/download handling, docs, and regression coverage.

Validation:
- pnpm test src/plugins/plugin-lookup-table.test.ts src/secrets/target-registry.fast-path.test.ts src/gateway/server-startup-post-attach.test.ts extensions/openrouter/video-generation-provider.test.ts src/video-generation/live-test-helpers.test.ts src/media-generation/provider-capabilities.contract.test.ts src/agents/pi-embedded-helpers/failover-matches.test.ts src/plugins/manifest-metadata-scan.test.ts src/agents/openai-transport-stream.test.ts src/media-understanding/openai-compatible-audio.test.ts src/agents/schema-normalization-runtime-contract.test.ts src/agents/provider-request-config.test.ts src/plugin-sdk/provider-stream.test.ts src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts -- --reporter=verbose
- OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=0 OPENCLAW_LIVE_VIDEO_GENERATION_MODELS=openrouter/google/veo-3.1-fast pnpm test:live src/video-generation/video-generation.live.test.ts -- --runInBand

Co-authored-by: notamicrodose <gabrielkripalani@me.com>
2026-04-28 10:57:31 +01:00
Peter Steinberger
5915489631 test: stabilize tts fast-lane guard 2026-04-28 10:54:23 +01:00
Peter Steinberger
6f8792f3f1 fix(cli): wire image describe prompt options 2026-04-28 10:53:53 +01:00
Peter Steinberger
0bc8b9a95a test: add shared OpenClaw test-state harness 2026-04-28 10:52:47 +01:00
Patrick Erichsen
ab3feca0d5 docs(skills): generalize pre-release testing skill wording (#73468) 2026-04-28 02:50:11 -07:00
Peter Steinberger
9207660c87 test: fix main ci shard routing 2026-04-28 10:48:27 +01:00
Vincent Koc
ae63f76bbd fix(cron): infer session agentId when omitted (#72326)
* fix(cron): infer session agentId when omitted

* fix(clownfish): address review for ghcrawl-165998-agentic-merge (1)
2026-04-28 02:47:20 -07:00
Peter Steinberger
c5cd7aabcf fix(auto-reply): bound pending tool result drain 2026-04-28 10:46:06 +01:00
Vincent Koc
210cccb0fe fix(tasks): index async media tasks by agent 2026-04-28 02:43:17 -07:00
Peter Steinberger
a6bb0265f0 test: speed up unit hotspot routing 2026-04-28 10:42:14 +01:00
Vincent Koc
17811480da docs(skills): add plugin pre-release test plan 2026-04-28 02:40:33 -07:00
Vincent Koc
cfbf4d1fa4 docs: note default sandbox image fail-fast behavior
For 47dc9f7fc0: docs/gateway/sandboxing.md now warns under "Build the default
image" that OpenClaw no longer silently retags plain debian:bookworm-slim as
openclaw-sandbox:bookworm-slim when the default image is missing. Sandbox runs
fail with a build instruction so the python3 tooling required by sandbox
write/edit helpers is preserved instead of being silently dropped.
2026-04-28 02:40:26 -07:00
Vincent Koc
058b57867e fix(qwen): allow explicit qwen3.6-plus on Coding Plan (#72664) 2026-04-28 02:38:47 -07:00
Peter Steinberger
b4ffef5c5f fix(plugins): prune inactive bundled runtime deps 2026-04-28 10:34:24 +01:00
Peter Steinberger
1346a31861 fix(plugins): keep manifestless bundles indexed 2026-04-28 10:34:01 +01:00
Peter Steinberger
f5922e6eb1 fix(agents): trim config write tool responses 2026-04-28 10:32:58 +01:00
Vincent Koc
5820a48fca ci: add plugin boundary codeql quality shard (#73447) 2026-04-28 02:30:33 -07:00
Peter Steinberger
1f1b98e33b fix(auto-reply): keep consumed reset triggers out of prompt 2026-04-28 10:24:04 +01:00
Vincent Koc
aa2f964bda fix(mattermost): keep inspector capture quiet 2026-04-28 02:19:57 -07:00
Vincent Koc
ad954dd1ca test(plugins): fix codex inspector capture regression 2026-04-28 02:19:56 -07:00
Vincent Koc
5f3b8b4100 fix(plugins): harden inspector runtime capture 2026-04-28 02:19:56 -07:00
Peter Steinberger
0f24a8d8e1 test: isolate gateway prewarm scheduling 2026-04-28 10:18:42 +01:00
Peter Steinberger
fac116cfa4 fix: resolve providerless image model refs 2026-04-28 10:18:07 +01:00
ZC
5741e40c14 fix(cron): clarify local timezone cron expressions (#73372)
* fix(cron): clarify local timezone cron expressions

* fix: clarify cron timezone guidance

---------

Co-authored-by: Altay <altay@uinaf.dev>
2026-04-28 12:16:27 +03:00
Peter Steinberger
9cdae734a7 test: stabilize gateway startup prewarm test 2026-04-28 10:14:03 +01:00
Vincent Koc
1912e309f7 fix(ui): confirm button-triggered new session resets (#73361) 2026-04-28 02:10:33 -07:00
Peter Steinberger
62997f7fce fix(deepseek): backfill v4 assistant reasoning replay 2026-04-28 10:07:39 +01:00
Peter Steinberger
0876ff481b test: speed up styled select test 2026-04-28 10:02:20 +01:00
Scott Hanselman
8f277e4b7f fix: allow safe Windows companion node commands (#71884)
Merged via squash.

Prepared head SHA: 24e2b79fe4
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Reviewed-by: @shanselman
2026-04-28 02:01:20 -07:00
Edwin Rivera
bca30b62be fix: defer Claude live MCP cleanup (#73351)
Thanks @edwin-rivera-dev.
2026-04-28 09:59:58 +01:00
Peter Steinberger
249cb54373 fix: keep acp typing for tool-only replies 2026-04-28 09:58:18 +01:00
Vincent Koc
7fd9c152d1 fix(memory): keep pre-compaction flush prompt out of user transcript (#73380) 2026-04-28 01:58:14 -07:00
Vincent Koc
47dc9f7fc0 Fix default sandbox image fallback for python3-dependent mutations (#73362) 2026-04-28 01:57:44 -07:00
Peter Steinberger
6f3b5f8666 fix(agents): pause yielded subagent runs 2026-04-28 09:57:12 +01:00
Peter Steinberger
2790825ae5 test(auto-reply): assert bare reset acknowledgement 2026-04-28 09:56:41 +01:00
Peter Steinberger
11f0244cf4 fix(gateway): start channels before model prewarm 2026-04-28 09:56:16 +01:00
Vincent Koc
b6a21cde34 ci: schedule android codeql shard (#73430) 2026-04-28 01:54:57 -07:00
Vincent Koc
76cd97289b fix(cron): support Telegram thread IDs in cron add/edit
- Add `--thread-id` support to cron add/edit Telegram delivery.
- Reject non-positive thread IDs and guard cron edit lookup pagination against non-progress/max-page loops.
- Preserve existing delivery mode on thread-only cron edit patches.

Carries forward #51581, #60373, and #60890.

Co-authored-by: ChunHao Chen <crazycjh@gmail.com>
2026-04-28 01:50:44 -07:00
Vincent Koc
02908db62b fix(ui): clear webchat pending state only for completed active run (#73368) 2026-04-28 01:47:00 -07:00
Peter Steinberger
3ed3248d7b fix(gateway): preserve config SecretRef env for services 2026-04-28 09:44:51 +01:00
Peter Steinberger
4c61040c52 test: speed up small unit fast cases 2026-04-28 09:44:44 +01:00
Vincent Koc
fe7865aad6 docs: cover Anthropic beta header suppression and claude-cli fallback prelude
- docs/concepts/model-providers.md: add proxy-route shaping rule for the
  09ec5d2c4d fix that suppresses implicit Anthropic beta headers
  (`claude-code-20250219`, `interleaved-thinking-2025-05-14`, OAuth markers)
  on non-direct endpoints, parallel to the existing OpenAI
  `compat.supportsDeveloperRole` rule.
- docs/gateway/cli-backends.md: add a "Fallback prelude from claude-cli
  sessions" section for a96f1fa5ef so users know that non-CLI fallback
  candidates after a claude-cli failure are now seeded with a context prelude
  harvested from Claude Code's `~/.claude/projects/` JSONL (preferring the
  latest `/compact` summary, coalescing tool blocks, skipping same-provider
  `--resume` fallbacks).
2026-04-28 01:42:25 -07:00
Peter Steinberger
8a98c08c8a fix(mattermost): avoid system events for user posts 2026-04-28 09:41:04 +01:00
Peter Steinberger
28bf71d74b fix(auto-reply): preserve silent voice payloads 2026-04-28 09:41:04 +01:00
Peter Steinberger
a3bbcf2792 fix(docker): keep plugin runtime deps off bind mounts 2026-04-28 09:37:59 +01:00
Peter Steinberger
3ee5490c60 fix(auto-reply): avoid duplicate reset hook acknowledgements 2026-04-28 09:37:15 +01:00
Vincent Koc
e2bcec33b3 fix(security): avoid duplicate skill package import 2026-04-28 01:37:01 -07:00
Vincent Koc
7e028917c0 fix(android): remediate app CodeQL alerts 2026-04-28 01:37:01 -07:00
Vincent Koc
5ac6d7661c fix(ci): harden workflow checkouts 2026-04-28 01:37:00 -07:00
Peter Steinberger
f76c8322d3 test: route gateway audit through fast lane 2026-04-28 09:35:34 +01:00
Vincent Koc
474859aaaa test(agents): cover raw model cache trace stage 2026-04-28 01:32:34 -07:00
Peter Steinberger
99ceaaa76e test: fix attempt execution fixture lint 2026-04-28 09:32:02 +01:00
Peter Steinberger
a68ca1ae0b fix(auto-reply): acknowledge bare reset commands 2026-04-28 09:31:14 +01:00
Peter Steinberger
8178b62187 fix(android): include third-party sensitive handlers 2026-04-28 09:27:59 +01:00
Peter Steinberger
2276f660f3 refactor(android): split sensitive features by flavor 2026-04-28 09:27:39 +01:00
Peter Steinberger
8ff0ea50b0 ci: stabilize full release validation 2026-04-28 09:26:50 +01:00
Vincent Koc
bab403d0ee fix(plugins): avoid bundled install load path aliases 2026-04-28 01:26:21 -07:00
Peter Steinberger
169dba2042 fix(skills): require opt-in for coding-agent 2026-04-28 09:24:24 +01:00
Peter Steinberger
4f6dab852e ci: fix main test and boundary checks 2026-04-28 09:23:26 +01:00
Peter Steinberger
09ec5d2c4d fix(agents): suppress Anthropic beta headers for custom endpoints 2026-04-28 09:20:58 +01:00
Peter Steinberger
2a1e47ffcb fix(agents): restore raw model run type coverage 2026-04-28 09:20:58 +01:00
Peter Steinberger
732e5805e3 fix(ollama): preserve configured native thinking 2026-04-28 09:20:44 +01:00
Peter Steinberger
7092313b2f docs: advertise xhigh docs i18n thinking 2026-04-28 09:19:40 +01:00
Peter Steinberger
db40ec404a fix: honor Ollama thinking catalog metadata 2026-04-28 09:15:28 +01:00
Peter Steinberger
67b16a4a6d fix: centralize source reply delivery mode 2026-04-28 09:14:19 +01:00
Peter Steinberger
1257e0e4ae ci: prepare qa channel boundary types 2026-04-28 09:13:49 +01:00
Peter Steinberger
4e921808d1 fix(line): persist inbound media in shared store 2026-04-28 09:12:11 +01:00
Peter Steinberger
fb3ea9efb1 fix: keep gateway model probes raw 2026-04-28 09:11:47 +01:00
Peter Steinberger
bce6c10290 fix: harden docs i18n prompt echoes 2026-04-28 09:11:28 +01:00
Peter Steinberger
725d557de6 fix(plugins): shorten runtime mirror lock hold 2026-04-28 09:10:37 +01:00
Peter Steinberger
0ef6702af3 build(android): update dependencies and lint config 2026-04-28 09:10:13 +01:00
Ayaan Zaidi
8da2fb1920 fix: seed claude-cli fallback context (#72069) (thanks @stainlu) 2026-04-28 13:35:59 +05:30
Ayaan Zaidi
5e4c29e9bc fix(agents): require claude fallback source provider 2026-04-28 13:35:59 +05:30
stainlu
4369c20bfe fix(agents): make originalProvider optional in runAgentAttempt params
The required-typed param introduced in 9987e7797f broke
attempt-execution.cli.test.ts and auth-profile-runtime-contract.test.ts
which construct runAgentAttempt params without an originalProvider field.
Make it optional and explicitly require the typeof check before passing
to isClaudeCliProvider so a missing field correctly skips the seed
(defensive default for fallback paths that didn't plumb the original
provider through, no-op for non-fallback paths).
2026-04-28 13:35:59 +05:30
stainlu
0bfcdcf044 fix(agents): scope claude-cli fallback seed and pair summary with boundary
Addresses review on #72069:

- Codex P1 ("Gate Claude prelude seeding by source provider"): the
  guard checked the *current* fallback candidate but not the failed
  attempt. A session that still carried a stale
  cliSessionBindings["claude-cli"] from an unrelated past run would
  inject Claude transcript context into a fallback chain that started
  on a different provider (e.g. openai -> openai-codex), leaking
  irrelevant prior conversation. Plumb `originalProvider` (the
  user-requested provider for the chain) through to runAgentAttempt
  and require `isClaudeCliProvider(originalProvider)` before reading
  Claude history.

- Codex P2 ("Prefer latest compact boundary when summary is missing"):
  the resolver always preferred the most recent explicit summary, so
  a later compaction without its own summary entry (rare crash case)
  paired stale summary text with post-latest-boundary turns. Restructure
  readClaudeCliFallbackSeed to queue summaries into pendingSummary and
  flush each boundary's pair atomically. A boundary with no preceding
  summary now correctly falls back to the boundary's own content
  rather than serving an older summary alongside fresh turns.

- Greptile P2 (newest-first break vs sparse coverage): the
  formatFallbackTurns walk intentionally stops on the first oversized
  turn so the prelude stays a contiguous "what was happening just
  before the failure" window. Document the design choice inline so a
  future maintainer doesn't reflexively change it to skip-and-continue.

Tests:
- New gateway cases for the boundary-without-summary edge case and
  for trailing summaries written without a paired boundary.
- existing 33 attempt-execution + 14 cli-session-history tests still
  pass; broader src/agents/command suite stays green (63/63).
2026-04-28 13:35:59 +05:30
stainlu
9691399e53 fix(agents): drop unnecessary non-null assertion in fallback prelude formatter
Local default oxlint did not run --type-aware so the warning was missed
on the initial commit; CI surfaced it via check-lint. Hoist the heading
into a named const so its length is read directly without the assertion.
2026-04-28 13:35:59 +05:30
stainlu
a96f1fa5ef fix(agents): seed claude-cli fallback prompts with prior-session context (#69973)
When a claude-cli attempt failed with a fallbackable error (e.g. a 402
billing limit), the next candidate -- typically a non-CLI provider --
ran with no prior conversation context. Claude Code keeps its own
JSONL session under ~/.claude/projects/, but the fallback runner only
sees what OpenClaw assembles from its own transcript, which is empty
for claude-cli sessions. The fallback model therefore behaved as if
the conversation just started, even though Claude later resumed fine.

Resolution mirrors what Claude Code itself does on resume after
compaction: prefer the explicit `/compact` summary, then append the
most recent post-boundary turns up to a char budget. Concretely:

- `readClaudeCliFallbackSeed` (gateway): walks the Claude JSONL with
  awareness of `type: "summary"` and `type: "system",
  subtype: "compact_boundary"` entries. Pre-boundary turns are dropped
  (they are represented by the summary); post-boundary turns become
  the recent-window. Multiple compactions are handled by preferring
  the latest summary. Path safety reuses the existing
  `resolveClaudeCliSessionFilePath` validation.

- `formatClaudeCliFallbackPrelude` / `buildClaudeCliFallbackContext\
Prelude` (agents helpers): format the harvested seed into a labeled
  prelude. Tool blocks are coalesced to compact "(tool call: name)" /
  "(tool result: …)" hints to keep the prompt budget honest. Newest
  turns are kept first when truncating; the summary is clearly
  labeled "(truncated)" if it overflows.

- `resolveFallbackRetryPrompt`: gains an optional
  `priorContextPrelude` that prepends before the existing retry
  marker. Empty/whitespace preludes are ignored; first-attempt prompts
  are unchanged.

- `runAgentAttempt`: builds the prelude when `isFallbackRetry === true`
  AND the new candidate is non-claude-cli AND a Claude-cli session
  binding is present. Same-provider fallbacks (claude-cli to
  claude-cli) are unaffected because Claude's own --resume still works.

Verified the new tests (12 in cli-session-history, 12 added to
attempt-execution) catch the regression: removing the prelude prepend
in resolveFallbackRetryPrompt makes both new prelude cases fail,
restoring the original cold-start behavior.

References:
- https://code.claude.com/docs/en/how-claude-code-works
- "Inside Claude Code: The Session File Format"
  https://databunny.medium.com/inside-claude-code-the-session-file-format-and-how-to-inspect-it-b9998e66d56b
2026-04-28 13:35:59 +05:30
Shakker
290c7ab848 test: add future strict startup benchmark case 2026-04-28 09:05:11 +01:00
Vincent Koc
dbab162abd ci: split codeql quality workflow (#73404) 2026-04-28 01:04:59 -07:00
Peter Steinberger
a811e164e3 ci: speed up full release validation 2026-04-28 09:02:57 +01:00
Peter Steinberger
c7af9c765c ci: tolerate missing clawsweeper dispatch access 2026-04-28 09:02:28 +01:00
Vincent Koc
a9a689ed2a fix(plugins): keep qa sdk aliases private 2026-04-28 01:01:19 -07:00
Peter Steinberger
f3191b7962 fix(agents): abort stalled Anthropic SSE reads 2026-04-28 09:00:37 +01:00
Peter Steinberger
a8b64b7d52 fix(doctor): require confirmation for transcript archive 2026-04-28 08:56:18 +01:00
Peter Steinberger
04e774eeac feat(android): add authenticated presence alive beacons (#73373)
* feat: add Android presence alive beacons

* fix: harden Android presence beacon review findings

* fix: address Android presence review findings
2026-04-28 08:55:06 +01:00
Peter Steinberger
c788aa025e test: route session lifecycle test through fast lane 2026-04-28 08:52:20 +01:00
Peter Steinberger
2d575bc00e fix(onboarding): pin health auth during setup 2026-04-28 08:51:29 +01:00
Peter Steinberger
8b4a5d70e4 fix(build): preserve staged runtime deps on rebuild 2026-04-28 08:45:11 +01:00
Zhang Xiaofeng
a0900926c3 fix: add CJK error patterns to failover classification (#56242)
* fix: add CJK error patterns to failover classification

Chinese LLM providers (ZhipuAI/GLM, Bailian, Kimi/Moonshot, DeepSeek,
etc.) return error messages in Chinese. The existing failover
classification only matches English patterns, causing these errors to
fall through as unclassified — surfacing raw provider errors to users
instead of triggering model fallback.

Real production example: ZhipuAI error code 1234 returns
'网络错误,错误id:xxx,请联系客服。' (network error). This was not
matched by the existing 'network error' English pattern, so no failover
was triggered despite having a configured fallback model.

Changes:
- Add Chinese patterns to all error categories in failover-matches.ts:
  timeout, serverError, rateLimit, billing, auth, overloaded
- Add Chinese network error detection in formatTransportErrorCopy()
  for user-friendly error messages
- Add comprehensive test coverage for all CJK error categories

Follows the existing precedent set by Chinese context overflow patterns
in isContextOverflowError().

* fix: narrow billing pattern and fix placeholder issue URL

- Change '账户余额' to '账户余额不足' to avoid false positives on
  messages that merely mention account balance (per greptile review)
- Replace XXXXX placeholder with actual issue #56242

* fix: wire CJK auth failover patterns

* fix: classify CJK provider failover errors

* fix: place failover changelog entry in unreleased

---------

Co-authored-by: Altay <altay@uinaf.dev>
2026-04-28 10:44:17 +03:00
Peter Steinberger
47b6d3a334 test(video): isolate provider registry mocks 2026-04-28 08:43:20 +01:00
Peter Steinberger
f95f720b25 docs: separate mintlify list closings 2026-04-28 08:43:20 +01:00
Peter Steinberger
a30698166b fix(wizard): pin setup token for health check 2026-04-28 08:43:20 +01:00
Galin Iliev
274d05dfe7 fix(wizard): use setup token for onboarding health check
Fixes #72203

Co-authored-by: OpenClaw Bot <bot@openclaw.dev>
2026-04-28 08:43:20 +01:00
Scott Hanselman
146debf8c1 fix(tui): dedupe ASCII backspace events (#73335)
Merged via squash.

Prepared head SHA: 8f02f48acd
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Reviewed-by: @shanselman
2026-04-28 00:41:55 -07:00
Vincent Koc
0b82a7e718 test(ci): align main test expectations 2026-04-28 00:35:44 -07:00
Peter Steinberger
1dd011984a fix: add pricing bootstrap opt-out and sdk compat exports 2026-04-28 08:35:11 +01:00
Peter Steinberger
f5a7632ffc ci: allow legacy package stamp warnings 2026-04-28 08:31:16 +01:00
Peter Steinberger
b22926601f fix(ui): keep chat attachment payloads out of state 2026-04-28 08:27:53 +01:00
Peter Steinberger
bb7e8624ab fix: keep typing for group message-tool replies 2026-04-28 08:27:23 +01:00
Peter Steinberger
2f3e81fec2 ci: guard docs against poisoned tool text 2026-04-28 08:27:11 +01:00
Peter Steinberger
bcf4628092 ci: use gpt-5.5 for live OpenAI defaults 2026-04-28 08:27:11 +01:00
Peter Steinberger
39cecd6428 ci: avoid unnecessary docker image pulls 2026-04-28 08:24:29 +01:00
Peter Steinberger
04e96c11ea fix(gateway): skip plugin pricing scans when disabled 2026-04-28 08:23:53 +01:00
Peter Steinberger
2cfe8e17f5 test: type channel list plugin stubs 2026-04-28 08:21:35 +01:00
Peter Steinberger
438da9596e test: expand fast lane coverage 2026-04-28 08:19:40 +01:00
Peter Steinberger
78a12706ec fix(docs): make docs formatter mintlify-safe 2026-04-28 08:13:21 +01:00
Peter Steinberger
e4139c3cb6 fix(cli): show configured chat channels in list 2026-04-28 08:12:56 +01:00
Peter Steinberger
bdba90a20b feat: add authenticated iOS background presence beacon (#73330)
* feat: add iOS background presence beacon

Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>

* fix: keep iOS background reconnects ahead of beacon throttle

* build: refresh gateway protocol swift models

* fix: emit swift protocol string enums

---------

Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
2026-04-28 08:10:35 +01:00
Vincent Koc
d525d6486d fix(android): keep camera temp files private
Fix Android CodeQL local temp-file disclosure findings in camera capture.
2026-04-28 00:06:12 -07:00
Peter Steinberger
85fcf16804 ci: align docs formatter with mintlify guard 2026-04-28 08:06:03 +01:00
Peter Steinberger
12962dd883 fix(models): keep agent primaries strict 2026-04-28 08:01:42 +01:00
Peter Steinberger
cd1343c244 docs: fix heartbeat paramfield lists 2026-04-28 08:00:27 +01:00
Thatgfsj
3dff1272e9 fix: harden Windows gateway restart fallback (#69056)
Thanks @Thatgfsj.
2026-04-28 07:57:47 +01:00
Peter Steinberger
07c653e913 test: move pure hotspots to fast lane 2026-04-28 07:56:40 +01:00
Peter Steinberger
acea3f2465 fix(build): stamp runtime postbuild artifacts 2026-04-28 07:56:08 +01:00
Peter Steinberger
3256cf4fc7 docs: clarify group visible replies 2026-04-28 07:55:40 +01:00
Ayaan Zaidi
6b6a049337 fix: collapse nested runtime deps cache roots (#73205) (thanks @SymbolStar) 2026-04-28 12:25:25 +05:30
SymbolStar
dfaa06fe15 fix(bundled-runtime-deps): collapse nested cache pluginRoot to enclosing key
When a bundled plugin (e.g. plugin-sdk loaded transitively) is resolved via a
pluginRoot already inside the existing plugin-runtime-deps cache, its path
does not match the `dist/extensions/<plugin>` shape, so
resolveBundledPluginPackageRoot() returns null and the caller falls back to
the raw pluginRoot. resolveExistingExternalBundledRuntimeDepsRoots() then
rejected the path because the relative segment crossed a directory separator,
causing the resolver to mint a fresh `openclaw-unknown-<pathhash>` cache
beside the real versioned one. The two caches raced replaceNodeModulesDir()
and triggered ENOTEMPTY crash loops.

Treat any descendant of `<base>/openclaw-*` as belonging to that cache key
so nested resolutions return the existing versioned root instead of creating
a self-referential zombie cache.

Fixes #72956
2026-04-28 12:25:25 +05:30
Peter Steinberger
424560c6c2 docs: normalize mintlify component closings 2026-04-28 07:54:15 +01:00
Peter Steinberger
8831d2cf0a fix: normalize docs mintlify components 2026-04-28 07:52:17 +01:00
Peter Steinberger
fb40ed99a7 fix(sessions): remove session store rotation 2026-04-28 07:46:24 +01:00
Peter Steinberger
ad57a6d616 docs: replace reactions cache bust with prose 2026-04-28 07:37:14 +01:00
Peter Steinberger
df4d3fa5a9 fix(logging): redact subsystem console output before colorizing 2026-04-28 07:36:50 +01:00
edwin-rivera-dev
f2df49ab4b fix(logging): redact secrets at subsystem console sink (#73284)
createSubsystemLogger writes through writeConsoleLine, which intentionally
bypasses the patched console.* capture handler in src/logging/console.ts to
avoid recursion. That bypass also skipped the sink-boundary
redactSensitiveText() gate, so secrets reaching subsystem loggers as
message strings or formatted meta could appear verbatim on the terminal —
a follow-up to the file-transport redaction landed in #67953, tracked
under #64046.

Apply redactSensitiveText() at the writeConsoleLine() exit, immediately
after the existing Windows surrogate sanitization and before dispatching
to the rawConsole sink. This covers all subsystem console paths
(trace/debug/info/warn/error/fatal and .raw) because they share the same
writeConsoleLine() exit, matching the redact-at-sink-boundary pattern
already used in console.ts and the file transport.

Closes #73284
2026-04-28 07:36:50 +01:00
scoootscooob
3c636208b0 fix(messages): keep group replies tool-only by default
Rewrites the always-on reply handling so group/channel rooms default to message-tool-visible output, while `messages.groupChat.visibleReplies: \"automatic\"` preserves legacy auto-posting.\n\nThanks @scoootscooob.
2026-04-28 07:36:43 +01:00
Peter Steinberger
e388f289bf docs: refresh reactions source cache key 2026-04-28 07:36:13 +01:00
Ke Wang
a253660385 fix(gateway): accept heartbeat/cron/webhook channel hints in agent params (#73237) (#73282)
* fix(gateway): accept heartbeat/cron/webhook channel hints in agent params (#73237)

* test(gateway): cover internal reply channel hints

* test(openai): include codex mini catalog expectation

* test(openai): follow codex catalog fixture split

---------

Co-authored-by: Ke Wang <ke@pika.art>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-28 07:32:23 +01:00
Peter Steinberger
f321036a00 fix(acpx): tolerate wrapper chmod failures 2026-04-28 07:30:00 +01:00
darkamenosa
cb8b327488 fix(zalouser): persist refreshed session cookies
Persist refreshed `zca-js` session cookies after QR login, session restore, and successful API calls so gateway restarts restore the freshest local Zalo Personal session.

- Adds stable credential cookie signatures so equivalent cookie-jar reorderings do not rewrite credentials.
- Adds regression coverage for reordered live cookie jars preserving credential file content and mtime.
- Updates CHANGELOG.md: (#73277) Thanks @darkamenosa.

Co-authored-by: Tuyen <hxtxmu@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-04-28 14:26:37 +08:00
Shakker
577a540880 docs: note fireworks together catalog migration 2026-04-28 07:25:03 +01:00
Shakker
7b3d3ce361 feat: declare together model catalog 2026-04-28 07:25:03 +01:00
Shakker
1aa62c0b0a feat: declare fireworks model catalog 2026-04-28 07:25:03 +01:00
Peter Steinberger
c3c8d66acf test: align acp fast-lane routing assertions 2026-04-28 07:22:14 +01:00
Peter Steinberger
4e6c0965cb test: route acp runtime tests through fast lane 2026-04-28 07:17:02 +01:00
Peter Steinberger
84477e014d test(openai): align codex runtime fixture 2026-04-28 07:08:27 +01:00
Frank Yang
e008830d0e fix(agents): clean up local Claude stdio runs (#73292)
Clean up local Claude stdio one-shot runs before returning from embedded `openclaw agent --local`, including bundle MCP loopback teardown for local process resources.

Keeps gateway-owned MCP loopback cleanup internal to the Gateway, documents the local-vs-gateway behavior, and aligns the stale OpenAI provider-runtime fixture with the current unsupported Codex mini route.
2026-04-28 07:06:01 +01:00
Peter Steinberger
9b556291e9 test(openai): split codex catalog fixtures 2026-04-28 07:04:22 +01:00
Vincent Koc
1278f0bcc0 fix(codeql): tune Android pinning profile
Remove noisy missing-certificate-pinning query from the critical Android CodeQL profile; gateway TLS uses custom certificate fingerprint pinning.
2026-04-27 23:04:16 -07:00
Vincent Koc
5828dcdb05 test(gateway): reduce server shard memory pressure (#73317) 2026-04-27 22:58:15 -07:00
Peter Steinberger
870f7d1c0f test(openai): align codex mini contract 2026-04-28 06:56:29 +01:00
Peter Steinberger
b5371bfd63 fix(auth): migrate flat auth profiles in doctor 2026-04-28 06:53:48 +01:00
Peter Steinberger
2f2aee5fe8 ci: retry cross-os agent runtime deps staging 2026-04-28 06:51:05 +01:00
Peter Steinberger
4397717322 fix(telegram): report unauthorized startup tokens 2026-04-28 06:50:51 +01:00
Peter Steinberger
76a07b9a07 fix(cli): reject empty model run prompts 2026-04-28 06:50:44 +01:00
Peter Steinberger
ee75a8ec2c ci: document clawsweeper dispatch trigger 2026-04-28 06:50:33 +01:00
Peter Steinberger
9aa461747a fix(plugin-sdk): restore legacy root alias exports 2026-04-28 06:48:59 +01:00
Peter Steinberger
6f3674c8d0 ci: harden ClawSweeper dispatcher credentials 2026-04-28 06:48:38 +01:00
Peter Steinberger
6543c10ab6 test: route model catalog through fast lane 2026-04-28 06:48:29 +01:00
Peter Steinberger
ba17db96a4 ci: skip clawsweeper without app credentials 2026-04-28 06:48:29 +01:00
Peter Steinberger
0113248d91 fix(gateway): route text-only chat images to media understanding 2026-04-28 06:45:28 +01:00
Peter Steinberger
0fc1cdec45 ci: fix ClawSweeper dispatcher payload 2026-04-28 06:44:26 +01:00
Peter Steinberger
dc6031197b fix(models): hide unsupported codex mini route 2026-04-28 06:43:51 +01:00
Peter Steinberger
23818600bb ci: add ClawSweeper event dispatcher 2026-04-28 06:43:38 +01:00
Ke Wang
b4e9f1bd1c fix(memory-core): cap detached dream narratives (#73287)
Cap detached Dream Diary narrative subagent runs across cron dreaming sweeps so multi-workspace runs cannot fan out unbounded subagent sessions.

Adds regression coverage that queued detached narratives resume and clean up, plus a unit-fast lane correction for the security symlink audit test.
2026-04-28 06:42:07 +01:00
Peter Steinberger
89079a32ef refactor(memory-host): narrow runtime adapters 2026-04-28 06:40:37 +01:00
Vincent Koc
29a34e0a4d fix(android): use absolute logcat path
Fix Android CodeQL relative path command finding in debug log collection.
2026-04-27 22:40:00 -07:00
Peter Steinberger
59a4d7fb06 fix(telegram): normalize bot endpoint api roots 2026-04-28 06:36:38 +01:00
Vincent Koc
27e313053c test(gateway): keep session event suite minimal
Keep the session message websocket suite on the default minimal gateway harness to avoid full startup for event routing coverage.
2026-04-27 22:35:40 -07:00
Peter Steinberger
252cc7eccf test: fix unit-fast config assertion 2026-04-28 06:34:50 +01:00
Peter Steinberger
5916237962 fix(onboard): infer custom model image input 2026-04-28 06:34:16 +01:00
Shakker
d48c3e12a5 feat: gate legacy startup sidecar fallback 2026-04-28 06:31:55 +01:00
Peter Steinberger
583b419827 test(plugins): lock package boundary bridges 2026-04-28 06:30:44 +01:00
Peter Steinberger
833654586e fix(gateway): keep container restarts in-process 2026-04-28 06:30:12 +01:00
roytong9
a3fd97570f Normalize telegram topic targets in delivery resolution (#59069)
* Normalize telegram topic targets in delivery resolution

* fix(cron): preserve explicit Telegram topic targets

* fix(clownfish): address review for ghcrawl-165998-agentic-merge (1)

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-27 22:27:42 -07:00
Vincent Koc
9577703249 test(gateway): trim cron server memory hotspots
Move pure cron coverage off websocket server RPC loops and clean up timeout listener retention in gateway test helpers.
2026-04-27 22:26:41 -07:00
Vincent Koc
2c58c5d4ec fix(android): avoid trust-all TLS probing
Fix Android CodeQL insecure trust manager finding in gateway TLS probing.
2026-04-27 22:26:27 -07:00
Vincent Koc
ce01b8f250 fix(gateway): keep restart probe auth local (#72405)
* fix(gateway): keep restart probe auth local

* fix(gateway): repair local restart probe auth replacement
2026-04-27 22:25:20 -07:00
Vincent Koc
4c72e605cd fix(feishu): recover mojibake filenames from Content-Disposition (#72388) 2026-04-27 22:23:16 -07:00
Vincent Koc
d7e67b455a fix(tui): clear stale streaming after orphaned finals (#72389)
* fix(tui): clear stale streaming after orphaned finals

* fix(tui): clear stale streaming after orphaned finals

* fix(tui): clear stale streaming after orphaned finals
2026-04-27 22:23:13 -07:00
Shakker
db7cab4a9a fix: simplify volc catalog model builders 2026-04-28 06:21:24 +01:00
Shakker
37324dd112 docs: note byteplus volcengine catalog migration 2026-04-28 06:21:24 +01:00
Shakker
8a3252868f refactor: remove unused volc catalog sdk helper 2026-04-28 06:21:24 +01:00
Shakker
1cfa22acb1 feat: declare volcengine model catalog 2026-04-28 06:21:24 +01:00
Shakker
4513658f59 feat: declare byteplus model catalog 2026-04-28 06:21:24 +01:00
Peter Steinberger
25851e3cae fix(google-meet): harden observe mode speech health (#73256)
* fix(google-meet): harden observe mode speech health

* fix(google-meet): address observe speech review

* docs(google-meet): clarify observe mode guarantees
2026-04-28 06:21:10 +01:00
Jesse Merhi
2633b14914 feat(security): support operator-managed network proxy routing (#70044)
* feat: support operator-managed proxy routing

* docs: add network proxy changelog entry

* fix(proxy): restrict gateway bypass to loopback IPs

* fix(cli): harden container proxy URL checks

* docs(proxy): clarify gateway bypass scope

* docs: remove proxy changelog entry

* fix(proxy): clear startup CI guard failures

* fix(proxy): harden gateway proxy policy parsing

* fix(proxy): honor update shorthand proxy policy

* fix(cli): redact proxy URL suffixes

* test(proxy): keep gateway help off proxy startup

* fix(proxy): keep overlapping lifecycle active

* docs: add proxy changelog entry

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-04-28 00:20:47 -05:00
Peter Steinberger
025081dbc5 refactor(memory-host): consolidate core adapter 2026-04-28 06:20:19 +01:00
Peter Steinberger
82eb90b8a2 fix(agents): preserve trusted tool media metadata 2026-04-28 06:19:41 +01:00
Peter Steinberger
bb97f19396 fix(telegram): preserve streamed generated media 2026-04-28 06:19:41 +01:00
Peter Steinberger
8c8dfa768a refactor(models): share catalog capability lookup 2026-04-28 06:18:54 +01:00
Shakker
defddedbaf fix: carry plugin compat into loader reports 2026-04-28 06:18:46 +01:00
Shakker
d062f8130b feat: warn on implicit startup plugin compatibility 2026-04-28 06:18:45 +01:00
Peter Steinberger
f7e942f571 fix(tasks): ship task registry control runtime 2026-04-28 06:18:30 +01:00
Peter Steinberger
85bdaff418 test: route security audits through fast lane 2026-04-28 06:18:06 +01:00
teamclaw
057b8276cc fix(config): align in-process write sourceConfig with file-watcher (#73267)
Fix config writes so in-process reload notifications use the canonical post-write source snapshot, matching the file watcher path.

Adds regression coverage for the runtime source snapshot and changelog credit.
2026-04-28 06:16:58 +01:00
Peter Steinberger
a644e30245 fix(memory-core): retry unavailable dreaming model 2026-04-28 06:15:28 +01:00
Peter Steinberger
017b8db616 ci: speed up release validation shards 2026-04-28 06:14:23 +01:00
Peter Steinberger
3d53b39917 fix(gateway): honor configured vision models 2026-04-28 06:10:14 +01:00
Peter Steinberger
88bcb64681 test: route acp session mapper through fast lane 2026-04-28 06:10:03 +01:00
Peter Steinberger
526372ea36 fix(gateway): use runtime config for secret-backed talk
* fix(gateway): use runtime config for secret-backed talk

* test(gateway): relax talk config rpc timeout

* refactor(gateway): clarify talk config resolution
2026-04-28 06:05:27 +01:00
Vincent Koc
75deb12606 fix(gateway): avoid approval route config load
Avoid eager runtime config loading in the gateway approval path and unref approval cleanup grace timers.
2026-04-27 22:04:09 -07:00
Peter Steinberger
ece523a2b0 docs(plugin-sdk): refresh api baseline 2026-04-28 06:02:17 +01:00
Peter Steinberger
f7d139dfef refactor(memory-host): localize host utilities 2026-04-28 06:02:17 +01:00
Peter Steinberger
74a667f119 fix(telegram): retry startup control calls on fallback transport 2026-04-28 06:02:05 +01:00
Vincent Koc
c627afe1df fix(ci): restore plugin sdk browser config wrapper 2026-04-27 22:01:55 -07:00
Vincent Koc
2809630036 fix(android): disable app data backup (#73281) 2026-04-27 22:01:28 -07:00
Vincent Koc
7b18bd03bb fix(gateway): allow explicit loopback trusted proxy auth
Fixes #59167.

Supersedes #63379.
2026-04-27 22:01:06 -07:00
Peter Steinberger
1089e8b9e0 fix: stabilize memory host ci tests 2026-04-28 06:00:21 +01:00
Peter Steinberger
a6141a5a41 fix: harden macOS gateway updates 2026-04-28 05:58:05 +01:00
Peter Steinberger
66f80d1ed6 docs: avoid mdx list in sdk overview tip 2026-04-28 05:56:57 +01:00
samzong
25ef9c0c41 [Feat] Gateway: offload non-image attachments on chat.send (#67572)
Merged via squash.

Prepared head SHA: ecbd27fc30
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-04-28 12:55:00 +08:00
Peter Steinberger
a68cc94c36 fix: resolve main ci shard failures 2026-04-28 05:52:19 +01:00
Peter Steinberger
540cbe24be fix: allow memory flush model override 2026-04-28 05:50:55 +01:00
Peter Steinberger
dc3df62e67 refactor(memory-host): own package contract surface 2026-04-28 05:49:07 +01:00
Vincent Koc
6fadc56802 fix(media): tighten sanitizeMimeType anchoring (#73229)
* fix(media): tighten sanitizeMimeType anchoring

* fix(media): tighten sanitizeMimeType anchoring

* fix(media): tighten sanitizeMimeType anchoring
2026-04-27 21:48:36 -07:00
Gustavo Madeira Santana
d59f001507 test(qa-matrix): cover allowBots modes 2026-04-28 00:47:40 -04:00
Vincent Koc
6d7901f5c8 fix(acpx): lazy-load startup backend 2026-04-27 21:46:45 -07:00
Peter Steinberger
996818e6af fix: follow up main ci failures 2026-04-28 05:41:49 +01:00
Peter Steinberger
8a48994802 fix(otel): record liveness warnings 2026-04-28 05:41:30 +01:00
Peter Steinberger
66a0aa47e4 docs(google): clarify gemini 3.1 pro alias 2026-04-28 05:41:30 +01:00
Vincent Koc
2bce63cb65 fix(android): harden canvas webview bridge (#73240)
* fix(android): harden canvas webview bridge

* fix(android): make canvas content access hardening explicit

* fix(android): keep webview hardening inline for CodeQL

* fix(android): avoid webview getter false positive
2026-04-27 21:41:01 -07:00
Peter Steinberger
52daf5fbd3 fix(acpx): stage Claude ACP adapter runtime dependency 2026-04-28 05:38:15 +01:00
Peter Steinberger
59bd7e47e8 docs: avoid mdx lists inside callouts 2026-04-28 05:34:44 +01:00
Peter Steinberger
b8c44bfc82 fix: restore main ci and speed tests 2026-04-28 05:34:28 +01:00
Brian Newman
055127425f fix(export): fix broken template placeholders in session export HTML (#41861)
* fix(export): fix broken template placeholders in session export HTML

The {{MARKED_JS}}, {{HIGHLIGHT_JS}}, and {{JS}} placeholders in the
export HTML template were split across multiple lines by a code
formatter, turning them into JS block statements instead of template
tokens. The generateHtml() function uses .replace('{{MARKED_JS}}', ...)
which requires contiguous strings, so the vendor JS and app code were
never injected — producing a 2MB HTML file that opens with styles and
session data but renders blank (no JS to parse/display the data).

Fix: collapse placeholders to single-line {{TOKEN}} format and add
prettier-ignore comments to prevent re-formatting.

Introduced in 9d403fd.

* fix(export): use function replacers for vendor JS injection

String.replace() interprets $ sequences ($&, $$, $', etc.) in
replacement strings. The minified vendor libraries (highlight.min.js,
marked.min.js) and the template JS contain literal $ characters that
get mutated during injection — e.g. $& becomes the matched placeholder
text, $$ becomes a single $.

Fix: use arrow function replacers for JS content so replacement text
is injected verbatim without $ interpretation. CSS and session data
use string replacers since they don't contain problematic $ patterns.

Flagged by Codex review (P2).

* ci: retrigger checks

* fix(export-session): restore inline export scripts

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-27 21:34:20 -07:00
Peter Steinberger
5826774076 fix(diagnostics-otel): handle liveness warnings 2026-04-28 05:32:40 +01:00
Peter Steinberger
b60eb1711a refactor(plugin-sdk): add managed task flow runtime 2026-04-28 05:32:40 +01:00
Peter Steinberger
d987e153fe docs: fix plugin architecture mdx 2026-04-28 05:32:19 +01:00
Peter Steinberger
03e0f17069 docs(changelog): consolidate 2026.4.27 notes 2026-04-28 05:31:19 +01:00
Shakker
c77aead063 docs: refresh plugin sdk api baseline 2026-04-28 05:30:04 +01:00
Shakker
31e01eb286 fix: narrow stepfun manifest provider keys 2026-04-28 05:30:04 +01:00
Shakker
d76540ff30 docs: update manifest catalog migration note 2026-04-28 05:30:04 +01:00
Shakker
c242f0c35f feat: declare stepfun model catalogs 2026-04-28 05:30:04 +01:00
Shakker
b3dce79af1 feat: declare tencent tokenhub model catalog 2026-04-28 05:30:04 +01:00
Shakker
fd484cf472 refactor: build deepseek catalog from manifest 2026-04-28 05:30:04 +01:00
Shakker
a4eb89c809 refactor: build moonshot catalog from manifest 2026-04-28 05:30:04 +01:00
Shakker
68a1dfb7e3 docs: document manifest provider catalog sdk helper 2026-04-28 05:30:04 +01:00
Shakker
a3ad2723cc fix: fail on dropped manifest catalog rows 2026-04-28 05:30:04 +01:00
Shakker
4168575b88 docs: note manifest provider catalog helper 2026-04-28 05:30:04 +01:00
Shakker
2d8ee0452e fix: normalize raw manifest provider catalogs 2026-04-28 05:30:04 +01:00
Shakker
a047144660 fix: narrow manifest catalog runtime inputs 2026-04-28 05:30:04 +01:00
Shakker
a36aeac072 fix: reject incomplete manifest provider catalogs 2026-04-28 05:30:04 +01:00
Shakker
129d5be507 refactor: build cerebras and mistral catalogs from manifests 2026-04-28 05:30:04 +01:00
Shakker
1f883f3dff refactor: build nvidia catalog from manifest 2026-04-28 05:30:04 +01:00
Shakker
833dcccddf refactor: build qianfan and xiaomi catalogs from manifests 2026-04-28 05:30:04 +01:00
Shakker
5cba55e520 feat: add manifest provider catalog helper 2026-04-28 05:30:04 +01:00
Peter Steinberger
1267a14326 docs: fix plugin architecture mdx 2026-04-28 05:29:26 +01:00
Peter Steinberger
cb1bca1a16 fix(diagnostics): export liveness warning telemetry 2026-04-28 05:28:04 +01:00
Peter Steinberger
001bf47727 chore(release): open 2026.4.27 development 2026-04-28 05:28:04 +01:00
Peter Steinberger
548f946ffd test(macos): remove conflict marker 2026-04-28 05:28:04 +01:00
Peter Steinberger
5dec95f35c test(macos): stabilize gateway control test 2026-04-28 05:28:04 +01:00
Peter Steinberger
35c9dd06b2 fix(cli): respect replace mode in model picker 2026-04-28 05:26:25 +01:00
Peter Steinberger
1a2f60c0a1 chore(browser): remove old security mock path 2026-04-28 05:21:58 +01:00
Peter Steinberger
af7f651db3 refactor(plugin-sdk): retire reserved helper exports 2026-04-28 05:21:57 +01:00
Peter Steinberger
870d993eb8 fix(ui): request configured model list 2026-04-28 05:21:08 +01:00
Peter Steinberger
000d52be37 ci: pin Google live gateway profile models 2026-04-28 05:19:33 +01:00
Vincent Koc
e8b4e39a97 fix(gateway): clear fallback context on close
Fixes gateway fallback request context cleanup on close/startup failure and shards the full gateway Vitest lane to avoid the observed memory hang.\n\nValidation:\n- Testbox: OPENCLAW_TESTBOX=1 pnpm check:changed\n- Testbox: env OPENCLAW_VITEST_MAX_WORKERS=1 /usr/bin/time -v pnpm test:gateway (254 files, 2950 tests, max RSS 4144692 KB)
2026-04-27 21:19:21 -07:00
Peter Steinberger
738f5f7508 fix: prevent channel login exec wedges 2026-04-28 05:16:43 +01:00
Peter Steinberger
ed98762832 fix: seed docs i18n codex auth 2026-04-28 05:15:38 +01:00
Peter Steinberger
843980e173 test: route more fast specs through unit-fast 2026-04-28 05:14:15 +01:00
Peter Steinberger
ab95812d65 fix: record model fallback steps in trajectories 2026-04-28 05:08:34 +01:00
Peter Steinberger
714f3b59cc fix: preserve unknown compaction failure detail 2026-04-28 05:08:34 +01:00
Shakker
34a0a9fd06 chore: benchmark startup-lazy plugins 2026-04-28 05:08:14 +01:00
Omar Shahine
4b760be1dd fix(gateway): strip SecretRef secret inputs from messages.tts.providers before talk.config hands them to speech providers (#73111)
Closes the gap left by #72496 on the parallel `messages.tts.providers.<id>` site. After #72496 landed, `talk.config` still threw `unresolved SecretRef` whenever an operator pinned a TTS apiKey or token as a SecretRef on the messages.tts side — same user-facing symptom (iOS / macOS / Control UI Talk overlays falling back to local AVSpeechSynthesizer).

Adds `stripUnresolvedSecretInputsFromBaseTtsProviders` in `src/gateway/server-methods/talk.ts` that walks each entry in `messages.tts.providers` and strips any unresolved SecretRef wrappers from the configured secret-input keys (`apiKey`, `token`) before handing the base TTS config down to `speechProvider.resolveTalkConfig`. Mirrors the `talk.providers` strip pattern from #72496.

Hardening: rebuilds the providers map with `Object.create(null)` instead of `{}` so an operator-config payload carrying `messages.tts.providers.__proto__` (or `constructor`/`prototype`) cannot mutate Object.prototype via the dynamic `cleaned[providerId] = ...` assignment. Caught by Aisle security review.

Adds three regression tests covering: SecretRef apiKey on messages.tts (the original bug), SecretRef token on messages.tts (Peter's generalization), and `__proto__`-keyed providers (Aisle hardening). All pass; full CI green (57/57) on the rebased branch.

Fixes #73109. Refs #72496.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:06:28 -07:00
Peter Steinberger
97f3e6d3c2 fix: keep docs i18n codex home out of tmp 2026-04-28 05:05:06 +01:00
Peter Steinberger
1e3ce10e27 refactor(plugin-sdk): remove unused reserved helper exports 2026-04-28 05:00:53 +01:00
Peter Steinberger
4d69f81a4e fix: isolate docs i18n codex home 2026-04-28 04:58:51 +01:00
Peter Steinberger
da773cf074 test: fix startup recovery model fixture types 2026-04-28 04:57:57 +01:00
Peter Steinberger
d9a6dd0c36 ci: pin OpenAI live gateway profile model 2026-04-28 04:57:48 +01:00
Vincent Koc
9a19d8b8ea fix(bonjour): classify ciao IPv4 changed assertion
Classify ciao's IPv4 address changed assertion spelling in the Bonjour plugin and cover the exact upstream message.
2026-04-27 20:56:43 -07:00
Peter Steinberger
f6c0aa256e Revert "fix: use API-supported docs i18n model"
This reverts commit d6d0506135.
2026-04-28 04:55:06 +01:00
Peter Steinberger
fed337b164 test: speed media runtime specs 2026-04-28 04:53:57 +01:00
Vincent Koc
6f38425e5c security(gateway): route hook completion events to target agent session (#73228) 2026-04-27 20:53:52 -07:00
Peter Steinberger
0f64887623 test(gateway): preserve startup model allowlist 2026-04-28 04:53:32 +01:00
Peter Steinberger
d6d0506135 fix: use API-supported docs i18n model 2026-04-28 04:53:22 +01:00
Vincent Koc
42de56cc22 fix(ci): trust live docker harness scripts 2026-04-27 20:52:37 -07:00
Peter Steinberger
76d279fe10 docs: note gateway restart version wait 2026-04-28 04:46:32 +01:00
pickaxe
b46ff081f7 Wait for gateway version during restart 2026-04-28 04:46:32 +01:00
SymbolStar
f53ec52e7d fix(bonjour): raise stuck announcing threshold
Raise the Bonjour stuck-announcing watchdog threshold from 8s to 20s and align watchdog timer coverage so healthy 12-13s LAN announcements do not trigger false-positive advertiser teardown.
2026-04-27 20:44:08 -07:00
Peter Steinberger
c17b9fe623 chore(plugins): add SDK retirement plan report 2026-04-28 04:42:55 +01:00
Peter Steinberger
1df48506a7 test: accept codex agent model list 2026-04-28 04:42:07 +01:00
Peter Steinberger
093dba3806 fix(acpx): bundle Codex ACP adapter 2026-04-28 04:39:41 +01:00
Peter Steinberger
4fb543796b refactor(plugin-sdk): annotate dormant reserved subpaths 2026-04-28 04:39:33 +01:00
Peter Steinberger
0ff60d162c test: type heartbeat overflow model fixtures 2026-04-28 04:39:00 +01:00
Vincent Koc
b1439ca527 fix(ci): keep codex live harness helpers trusted 2026-04-27 20:37:57 -07:00
Peter Steinberger
4eb8a7d586 test: align run main commander mock 2026-04-28 04:34:41 +01:00
Peter Steinberger
995b51d309 test: fix qr cli runtime mock hoisting 2026-04-28 04:34:41 +01:00
Doncic
bf60e3ed31 fix(test): resolve vitest mock hoisting in qr-cli.test.ts 2026-04-28 04:34:41 +01:00
Peter Steinberger
0bdc1d0375 ci: hydrate provider env for testbox commands 2026-04-28 04:34:21 +01:00
Peter Steinberger
2860592302 fix(discord): hand off interactions asynchronously 2026-04-28 04:33:57 +01:00
Shakker
6f13982212 test: assert bundled startup plan metadata 2026-04-28 04:33:48 +01:00
Shakker
08cc44b57d feat: lazily load tool result middleware plugins 2026-04-28 04:33:47 +01:00
Shakker
fc3b8ad3ee fix: startup load skill workshop hooks 2026-04-28 04:33:47 +01:00
Shakker
c7b1f1285f test: fix bundled startup guard typing 2026-04-28 04:33:47 +01:00
Shakker
61ddddbe0f test: require bundled startup activation metadata 2026-04-28 04:33:47 +01:00
Shakker
86bdeb0561 perf: mark capability plugins startup lazy 2026-04-28 04:33:47 +01:00
Shakker
97016fbf02 perf: mark channel plugins startup lazy 2026-04-28 04:33:47 +01:00
Shakker
00d2c34889 perf: mark provider plugins startup lazy 2026-04-28 04:33:47 +01:00
Shakker
f1aaa2cd91 feat: declare startup plugin imports explicitly 2026-04-28 04:33:47 +01:00
Peter Steinberger
3945193257 fix: use codex for docs i18n 2026-04-28 04:33:41 +01:00
Peter Steinberger
b2d102109b fix(telegram): retry webhook registration failures 2026-04-28 04:33:22 +01:00
Peter Steinberger
5a2e5446a4 fix: explain heartbeat model bleed overflows 2026-04-28 04:32:55 +01:00
Peter Steinberger
68561a8c94 ci: use trusted codex live harness 2026-04-28 04:29:35 +01:00
Peter Steinberger
dfc14d1653 test: accept current codex status wording 2026-04-28 04:27:29 +01:00
Peter Steinberger
6c0cdf43e4 fix: honor subagent spawn model overrides 2026-04-28 04:25:31 +01:00
Peter Steinberger
e7495e2d92 ci: pass provider secrets to testbox 2026-04-28 04:24:15 +01:00
Peter Steinberger
38ba27834d chore: harden plugin boundary report 2026-04-28 04:23:53 +01:00
Peter Steinberger
073b3fbf88 test: move more runtime specs to fast lane 2026-04-28 04:23:48 +01:00
Vincent Koc
c205577f2c fix(cli): keep gateway run on fast path 2026-04-27 20:22:52 -07:00
Peter Steinberger
758262e1e3 test: keep live shard release partition unique 2026-04-28 04:20:49 +01:00
Peter Steinberger
379c43c754 test: align compatibility guard expectations 2026-04-28 04:20:49 +01:00
Peter Steinberger
070e2427bf test: remove stale root test helper bridges 2026-04-28 04:20:49 +01:00
Peter Steinberger
dab0e57914 style: format sdk helper imports 2026-04-28 04:20:49 +01:00
Peter Steinberger
896b82f430 test: align sdk helper imports 2026-04-28 04:20:49 +01:00
Peter Steinberger
aa6417b93d test: align doctor plugin manifest mocks 2026-04-28 04:20:49 +01:00
Peter Steinberger
993fee4066 fix(agents): avoid empty Anthropic tool result blocks 2026-04-28 04:20:49 +01:00
Vincent Koc
4102f8d28d fix(macos): parse model catalog without JavaScriptCore
Replaces JavaScriptCore catalog evaluation with a bounded fail-closed object-literal parser for the generated macOS model catalog.\n\nValidation: macos-node, macos-swift, security-fast, security-scm-fast, security-dependency-audit, workflow sanity checks passed on PR #73112.
2026-04-27 20:16:51 -07:00
Peter Steinberger
4b4cde7187 fix(memory): back off qmd open failures 2026-04-28 04:16:25 +01:00
Peter Steinberger
4db4d8976d ci: run release validation with trusted harness 2026-04-28 04:14:09 +01:00
Peter Steinberger
343f2d7245 fix: fail closed for invalid cron payload models 2026-04-28 04:12:54 +01:00
Peter Steinberger
00e30ba8d9 chore: add plugin boundary report 2026-04-28 04:12:30 +01:00
Gustavo Madeira Santana
ae616777f3 test(qa-matrix): cover approval metadata scenarios 2026-04-27 23:10:51 -04:00
Gustavo Madeira Santana
795e58acf2 test(matrix): cover approval metadata delivery 2026-04-27 23:10:51 -04:00
Peter Steinberger
b1a36226b1 test: stabilize faster unit lanes 2026-04-28 04:09:41 +01:00
Peter Steinberger
e11eb03182 fix: exclude plugin dependencies from backups 2026-04-28 04:03:20 +01:00
Peter Steinberger
719ec4f292 refactor: share OpenAI-compatible image provider 2026-04-28 04:01:43 +01:00
Peter Steinberger
358579b136 test: guard extension test api exports 2026-04-28 04:00:00 +01:00
Peter Steinberger
a812b8f919 test: use public plugin sdk test fixtures 2026-04-28 03:52:38 +01:00
Peter Steinberger
518d568de5 test: cover staged bundled facade deps 2026-04-28 03:52:24 +01:00
Peter Steinberger
129b996a4e refactor: tighten extension test support boundaries 2026-04-28 03:52:19 +01:00
Peter Steinberger
e5452a9c57 ci: speed up release validation 2026-04-28 03:52:05 +01:00
Peter Steinberger
f549703bed test: route more safe files to unit fast 2026-04-28 03:47:31 +01:00
Peter Steinberger
e9611e74a1 test: fix core support boundary helpers 2026-04-28 03:47:31 +01:00
Peter Steinberger
07494a43fc chore(release): publish 2026.4.26 appcast 2026-04-28 03:47:20 +01:00
Peter Steinberger
65b605569b docs: record release tweet workflow 2026-04-28 03:47:20 +01:00
Peter Steinberger
fc0a2bc87d fix: show banner on gateway fast path 2026-04-28 03:46:05 +01:00
Peter Steinberger
cfca2d4051 refactor: move remaining agent test contract files 2026-04-28 03:40:57 +01:00
Peter Steinberger
2628326264 refactor: expose agent runtime test contracts 2026-04-28 03:40:57 +01:00
Peter Steinberger
c1c9f5f1a3 test: speed up unit fast lane 2026-04-28 03:37:14 +01:00
Peter Steinberger
09a2ffc47a fix: prepare public artifact runtime deps 2026-04-28 03:34:53 +01:00
Peter Steinberger
35685e9960 refactor: centralize plugin gateway message dispatch 2026-04-28 03:28:51 +01:00
Peter Steinberger
7bf08e7344 refactor: move remaining SDK test helper files 2026-04-28 03:28:17 +01:00
Peter Steinberger
e1acb61317 refactor: expose SDK test helper subpaths 2026-04-28 03:28:17 +01:00
Shakker
21528222c3 docs: note static provider catalog manifests 2026-04-28 03:26:57 +01:00
Shakker
a30632eb28 feat: declare cerebras and mistral model catalogs 2026-04-28 03:26:57 +01:00
Shakker
7f87593548 feat: declare nvidia model catalog 2026-04-28 03:26:57 +01:00
Shakker
2d7b16e0db feat: declare qianfan and xiaomi model catalogs 2026-04-28 03:26:57 +01:00
Peter Steinberger
88068b9649 fix: prepare bundled facade runtime deps 2026-04-28 03:25:01 +01:00
Peter Steinberger
4a54682275 fix: tolerate stale plugin index channel metadata 2026-04-28 03:23:45 +01:00
2281 changed files with 88043 additions and 24301 deletions

View File

@@ -293,9 +293,15 @@ checks that need parity or remote state.
5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
7. If you need artifacts (coverage reports, build outputs, etc.), download them:
7. If a narrow PR reports a full sync or the box was reused/expired, sanity
check the remote copy before a slow gate:
`blacksmith testbox run --id <ID> "pnpm testbox:sanity"`.
If it reports missing root files or mass tracked deletions, stop the box and
warm a fresh one. Use `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` only for an
intentional large deletion PR.
8. If you need artifacts (coverage reports, build outputs, etc.), download them:
`blacksmith testbox download --id <ID> coverage/ ./coverage/`
8. Once green, commit and push.
9. Once green, commit and push.
## OpenClaw full test suite
@@ -314,6 +320,12 @@ When validating before commit/push in maintainer Testbox mode, run
`pnpm check:changed` inside the warmed box first when appropriate, then the full
suite with the profile above if broad confidence is needed.
Run `pnpm testbox:sanity` inside the warmed box before the broad command when
the sync looks suspicious. It checks that root files such as `pnpm-lock.yaml`
still exist and fails on 200 or more tracked deletions. That catches stale or
corrupted rsync state before dependency install or Vitest failures hide the real
problem.
## Examples
blacksmith testbox warmup ci-check-testbox.yml

View File

@@ -0,0 +1,138 @@
---
name: clawsweeper
description: Inspect ClawSweeper commit-review and issue/PR-sweeper reports for OpenClaw, including recent per-commit reports, finding summaries, GitHub Checks, Actions monitoring, manual backfills, and report links.
---
# ClawSweeper
ClawSweeper lives at `~/Projects/clawsweeper`. Use this skill when Peter asks
about ClawSweeper reports, commit-review checks, recent findings, historic
backfills, or whether the sweeper/dispatch lane is healthy.
## Start
```bash
cd ~/Projects/clawsweeper
git status --short
git pull --ff-only
pnpm run build
```
Do not overwrite unrelated local edits. If the tree is dirty, inspect status
and keep report-reading commands read-only unless Peter asked to commit.
## Recent Commit Reports
Canonical reports are flat:
```text
records/<repo-slug>/commits/<40-char-sha>.md
```
Use the lister instead of browsing date folders:
```bash
pnpm commit-reports -- --since 6h
pnpm commit-reports -- --since "24 hours ago" --findings
pnpm commit-reports -- --since 7d --non-clean
pnpm commit-reports -- --repo openclaw/openclaw --author steipete --since 7d
pnpm commit-reports -- --since 24h --json
```
One report per commit. Reruns overwrite the same SHA-named file. Results:
`nothing_found`, `findings`, `inconclusive`, `failed`, `skipped_non_code`.
## Monitor Actions
Receiver lane in `openclaw/clawsweeper`:
```bash
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--limit 12 --json databaseId,displayTitle,event,status,conclusion,createdAt,updatedAt,url
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--status in_progress --limit 20 --json databaseId,displayTitle,event,status,createdAt,url
```
Target dispatcher in `openclaw/openclaw`:
```bash
gh run list --repo openclaw/openclaw --workflow "ClawSweeper Dispatch" \
--event push --limit 8 --json databaseId,displayTitle,event,status,conclusion,headSha,url
git ls-remote https://github.com/openclaw/openclaw.git refs/heads/main
```
Check the target commit's published report check:
```bash
gh api "repos/openclaw/openclaw/commits/<sha>/check-runs?per_page=100" \
--jq '.check_runs[] | select(.name=="ClawSweeper Commit Review") | [.status,.conclusion,.details_url] | @tsv'
```
## Manual Commit Rerun / Backfill
Use the receiver workflow when Peter asks to rerun a specific commit report,
review a specific commit, or backfill a historic range. Reruns overwrite the
same canonical report file:
`records/<repo-slug>/commits/<40-char-sha>.md`.
Single-commit rerun:
```bash
gh workflow run commit-review.yml --repo openclaw/clawsweeper \
-f target_repo=openclaw/openclaw \
-f commit_sha=<sha> \
-f before_sha=<parent-sha> \
-f create_checks=false \
-f enabled=true
```
Historic range backfill:
```bash
gh workflow run commit-review.yml --repo openclaw/clawsweeper \
-f target_repo=openclaw/openclaw \
-f commit_sha=<end-sha> \
-f before_sha=<start-sha> \
-f create_checks=false \
-f enabled=true
```
Use `create_checks=true` only when Peter explicitly wants target commit check
runs. Checks are opt-in; markdown reports are the primary surface.
For a targeted rerun with extra instructions, add `additional_prompt`:
```bash
-f additional_prompt="Review this commit with focus on <topic>."
```
After dispatch, monitor and then pull the regenerated report:
```bash
gh run list --repo openclaw/clawsweeper --workflow "ClawSweeper Commit Review" \
--limit 5 --json databaseId,displayTitle,status,conclusion,url
gh run watch <run-id> --repo openclaw/clawsweeper --interval 30 --exit-status
git pull --ff-only
sed -n '1,180p' records/openclaw-openclaw/commits/<sha>.md
```
## Report Reading
Lead with counts and useful findings:
```bash
pnpm commit-reports -- --since 24h
pnpm commit-reports -- --since 24h --findings
```
If findings exist, open the markdown report and summarize:
- SHA and author/co-authors
- result, confidence, severity, check conclusion
- concrete finding and affected file
- whether the report includes tests/live checks
- GitHub report URL:
`https://github.com/openclaw/clawsweeper/blob/main/<report-path>`
Do not post GitHub comments from this lane. Commit Sweeper's public surfaces are
markdown reports and the `ClawSweeper Commit Review` check.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "ClawSweeper"
short_description: "Inspect ClawSweeper commit review reports and Actions runs."
default_prompt: "Review recent ClawSweeper commit reports and summarize findings."

View File

@@ -0,0 +1,89 @@
---
name: clownfish-cloud-pr
description: Use when launching Clownfish in GitHub Actions to create or update one guarded GitHub implementation PR from issue/PR refs, a ClawSweeper report, or a custom maintainer prompt.
---
# Clownfish Cloud PR
Use this skill when the user wants Codex to ask Clownfish to create a PR in the
cloud from issue/PR refs plus a custom prompt.
## Create One Job
```bash
cd ~/Projects/clownfish
git status --short --branch
npm run create-job -- \
--repo openclaw/openclaw \
--refs 123,456 \
--prompt-file /tmp/clownfish-prompt.md
```
From a ClawSweeper report:
```bash
npm run create-job -- \
--from-report ../clawsweeper/records/openclaw-openclaw/items/123.md
```
The script checks for an existing open PR/body match and remote branch named
`clownfish/<cluster-id>` before writing a duplicate job. Use `--dry-run` to
inspect the exact job body.
## Validate And Dispatch
```bash
npm run validate:job -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md
npm run render -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md --mode autonomous >/tmp/clownfish-rendered-prompt.md
git add jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md
git commit -m "chore: add ClawSweeper promoted job"
git push origin main
npm run dispatch -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md \
--mode autonomous \
--runner blacksmith-4vcpu-ubuntu-2404 \
--execution-runner blacksmith-16vcpu-ubuntu-2404 \
--model gpt-5.5
```
Do not use `--dispatch` until the job is committed and pushed; the workflow
reads the job path from GitHub. Keep `CLOWNFISH_ALLOW_MERGE=0` unless Peter
explicitly opens the merge gate.
## Maintainer Comment Commands
Clownfish can also be asked from target repo comments, but only by maintainers.
Use `/clownfish ...` or `@openclaw-clownfish ...`; do not use `@clownfish`
because that is a separate GitHub user.
Supported commands:
```text
/clownfish status
/clownfish fix ci
/clownfish address review
/clownfish rebase
/clownfish explain
/clownfish stop
@openclaw-clownfish fix ci
```
The router accepts `OWNER`, `MEMBER`, and `COLLABORATOR` comments by default.
Contributor comments are ignored without a reply. Repair commands dispatch
`cluster-worker.yml` only for existing Clownfish PRs with the `clownfish` label
or `clownfish/*` branch.
```bash
npm run comment-router -- --repo openclaw/openclaw --lookback-minutes 180
npm run comment-router -- --repo openclaw/openclaw --execute --wait-for-capacity
```
Scheduled routing stays dry until `CLOWNFISH_COMMENT_ROUTER_EXECUTE=1` is set in
`openclaw/clownfish` repo variables.
## Guardrails
- One cluster, one branch, one PR: `clownfish/<cluster-id>`.
- No security-sensitive work.
- Do not close duplicates before the fix PR path exists, lands, or is proven
unnecessary.
- Codex workers do not get GitHub tokens; deterministic scripts own writes.

View File

@@ -0,0 +1,234 @@
---
name: openclaw-pre-release-plugin-testing
description: Plan and run pre-release OpenClaw plugin validation across bundled plugins, package artifacts, lifecycle commands, doctor/fix, config round-trip, gateway startup, SDK compatibility, Docker E2E, Package Acceptance, and Testbox proof.
---
# OpenClaw Pre-Release Plugin Testing
Use this skill when the user asks for plugin release confidence, plugin lifecycle
sweeps, package-artifact plugin proof, or "what else should we test before
release?" It complements `openclaw-testing`; use that skill too when choosing
the cheapest safe runner or debugging a failing lane.
## Goal
Prove the plugin system as a product surface, not just as source tests:
- bundled plugin lifecycle: install, inspect, enable, disable, uninstall
- package artifact behavior from a clean `HOME`
- doctor/fix/config validation and idempotence
- config discovery and config round-trip
- status/log visibility and diagnostics
- gateway startup/bootstrap with plugin metadata snapshots
- public SDK compatibility for real external plugins
- live-ish provider/channel probes only when safe credentials exist
## First Checks
From the OpenClaw repo root:
```bash
pnpm docs:list
git status --short --branch
readlink node_modules
pnpm changed:lanes --json
```
In Codex worktrees under `.codex/worktrees`, `node_modules` must be a symlink to
the main OpenClaw checkout. Do not run `pnpm install` there. For broad or
package-heavy proof, use Blacksmith Testbox or GitHub Actions.
## Runner Choice
Prefer this order:
1. **GitHub Package Acceptance** for installable-package product proof.
2. **`ci-build-artifacts-testbox.yml` Testbox** when Docker/package lanes need
seeded `dist`, `dist-runtime`, and package caches.
3. **`ci-check-testbox.yml` Testbox** for source checks, targeted Vitest,
package-boundary checks, or focused Docker lanes.
4. **Local targeted commands only** for small format/static/unit probes.
Avoid long package Docker runs from a stale sparse worktree. If Testbox sync
reports hundreds of changed files or starts deleting package inputs, stop and
warm a fresh box from current `main`, or switch to Package Acceptance.
## Existing Baseline
Run or verify these before inventing new coverage:
```bash
OPENCLAW_TESTBOX=1 pnpm check:changed
pnpm run test:extensions:package-boundary:canary
pnpm run test:extensions:package-boundary:compile
pnpm test:docker:plugins
OPENCLAW_PLUGINS_E2E_CLAWHUB=0 pnpm test:docker:plugins
pnpm test:docker:plugin-update
pnpm test:docker:bundled-channel-deps:fast
```
For full bundled install/uninstall proof, shard the packaged sweep:
```bash
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL=8 \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX=<0-7> \
pnpm test:docker:bundled-plugin-install-uninstall
```
Expected current packaged scope: 116 public bundled plugins over shards `0-7`.
Private QA plugins are source-mode only unless a package explicitly includes
them.
## Confidence Matrix
Use this matrix for pre-release signoff. Record pass/fail, run URL/Testbox ID,
package SHA/version, and skipped-live reason.
| Surface | Proof | Preferred runner |
| --- | --- | --- |
| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions |
| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker |
| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance |
| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance |
| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance |
| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness |
| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness |
| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness |
| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke |
| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes |
## Package Acceptance Plan
Use this when validating a release branch, beta, or candidate package:
```bash
gh workflow run package-acceptance.yml \
--repo openclaw/openclaw \
--ref main \
-f workflow_ref=main \
-f source=ref \
-f package_ref=<branch-or-sha> \
-f suite_profile=custom \
-f docker_lanes='plugins-offline plugin-update bundled-channel-deps-compat doctor-switch update-channel-switch config-reload mcp-channels npm-onboard-channel-agent' \
-f telegram_mode=mock-openai
```
Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
`workflow_ref` as trusted current harness code unless the release process says
otherwise.
## New Testbox Harness Plan
If more certainty is needed, add or run a `plugin-lifecycle-matrix` Docker lane
that uses one package tarball and sharded plugin lists. Per plugin:
1. Start with a clean `HOME`.
2. Capture `plugins list --json`.
3. `plugins install <id>`.
4. `plugins inspect <id> --json`.
5. `plugins disable <id>`, then assert disabled visibility.
6. `plugins enable <id>`, except config-required plugins without config.
7. `plugins registry --refresh`.
8. `doctor --non-interactive`.
9. `plugins uninstall <id> --force`.
10. Assert no config entry, allow/deny residue, install record, managed dir, or
bundled `dist/extensions/...` load path remains.
11. Assert diagnostics contain no `level: "error"` and output redacts
secret-looking values.
Keep `memory-lancedb` special: it is config-required. First assert install does
not enable it without embedding config, then run a second configured case.
## Doctor/Fix Matrix
Seed bad states and require `doctor --fix --non-interactive` to repair them,
then run doctor again and require idempotence:
- stale `plugins.allow`
- stale `plugins.entries`
- stale channel config for missing channel plugin
- invalid `plugins.entries.<id>.config`
- packaged bundled path in `plugins.load.paths`
- legacy `plugins.installs`
- disabled channel/plugin config that must not stage runtime deps
- root-owned global package tree that must remain unmodified
## Gateway Bootstrap Matrix
Start packaged OpenClaw in Docker with clean state:
- provider plugins enabled, no credentials: ready with warnings, no crash
- channel plugins configured disabled: no runtime deps staged
- startup-activation plugins enabled: ready and reflected in status
- invalid single plugin config: bad plugin skipped/quarantined, others remain
Assert:
- gateway reaches ready
- `openclaw status --json` includes plugin diagnostics
- `openclaw plugins inspect --all --json` is parseable
- package tree is not mutated
- logs contain no raw tokens
## Config Round-Trip Representatives
Use representative plugin families instead of every plugin for deep config
round-trip:
- providers: `openai`, `anthropic`, `mistral`, `openrouter`
- channels: `telegram`, `discord`, `slack`, `whatsapp`
- memory: `memory-lancedb`
- feature/runtime: `browser`, `acpx`, `tokenjuice`
For each representative:
1. Write config through CLI when possible.
2. Read it back through `config get` or JSON.
3. Run `plugins inspect`.
4. Run `doctor --non-interactive`.
5. Trigger gateway config reload if applicable.
6. Compare config hash before/after no-op commands.
## External SDK Smoke
In a package Docker lane, create tiny external plugins and install them from:
- local directory
- `.tgz`
- `file:` npm spec
Cover CJS and ESM shapes, plus at least one plugin importing focused
`openclaw/plugin-sdk/*` subpaths. Assert `plugins inspect` sees its tool,
gateway method, CLI command, or service.
## Live-Ish Probe Rules
Before live-ish work, source allowed env in Testbox and generate a redacted
availability matrix: present/missing only, never values.
Only run probes for credentials that exist. Prefer auth/catalog/status probes
over sending user-visible messages. If a probe might contact an external user,
channel, or workspace, stop and ask the user.
## Reporting
Report in this shape:
```text
package/ref:
tbx ids / run urls:
matrix:
bundled lifecycle:
package acceptance:
doctor/fix:
gateway bootstrap:
config round-trip:
sdk external:
live-ish:
failures:
skips:
next highest-value gap:
```
Say clearly when a failure is Testbox sync/env damage rather than product
behavior, and prove that with a clean rerun or current-main comparison.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw Plugin Pre-Release Testing"
short_description: "Plan plugin release validation"
default_prompt: "Use $openclaw-pre-release-plugin-testing to plan or run pre-release OpenClaw plugin validation across package, lifecycle, doctor, gateway, SDK, and live-ish proof."

View File

@@ -181,6 +181,9 @@ live`; keep it clearly beta and avoid implying stable promotion.
compact launch post, then publish one focused feature explainer per reply.
Follow-up replies should not repeat "new in VERSION" or the version number
when the thread context already makes it obvious.
- Peter's preferred thread workflow: first agree on the generic launch tweet,
then proceed through follow-up tweets one by one. When he says `next`, provide
or copy the next follow-up only; do not dump the full thread again unless asked.
- Every follow-up tweet should include a docs URL for that specific feature.
Prefer a bare URL over `Docs: <url>` unless the label is needed for clarity.
Keep follow-ups concise: around 160-220 raw characters is usually the sweet

View File

@@ -1,12 +1,13 @@
---
name: openclaw-test-performance
description: Benchmark, diagnose, and optimize OpenClaw test runtime, import hotspots, CPU/RSS, and slow coverage paths.
description: Benchmark, diagnose, and optimize OpenClaw test and plugin-suite runtime, import hotspots, CPU/RSS, heap growth, and slow coverage paths.
---
# OpenClaw Test Performance
Use evidence first. The goal is real `pnpm test` speed/RSS improvement with
coverage intact, not runner tuning by guesswork.
Use evidence first. The goal is real `pnpm test`, plugin-suite, and
plugin-inspector speed/RSS improvement with coverage intact, not runner tuning by
guesswork.
## Workflow
@@ -21,6 +22,9 @@ coverage intact, not runner tuning by guesswork.
2. Establish a baseline before changing code:
- Prefer `pnpm test:perf:groups --full-suite --allow-failures --output <file>`
for full-suite ranking.
- For bundled plugin breadth, run the smallest relevant `pnpm
test:extensions:batch <plugin[,plugin...]>` or plugin-inspector command
before jumping to the full extension sweep.
- For a scoped hotspot use:
`/usr/bin/time -l pnpm test <file-or-files> --maxWorkers=1 --reporter=verbose`
- For import-heavy suspicion add:
@@ -33,6 +37,8 @@ coverage intact, not runner tuning by guesswork.
passed, capture that as harness/noise and verify the suspect file directly.
4. Pick the next attack by return and risk:
- High return: one file/test dominates seconds or RSS and has a clear root.
- High leverage: one plugin or SDK barrel causes every plugin-inspector or
extension-batch run to load broad runtime.
- Lower risk: static descriptors, target parsing, routing, auth bypass,
setup hints, registry fixtures, or test server lifecycle.
- Higher risk: real memory/runtime behavior, live providers, protocol
@@ -44,6 +50,8 @@ coverage intact, not runner tuning by guesswork.
and pure helpers over broad mocks.
- Reuse suite-level servers/clients when a fresh handshake is irrelevant.
- Keep schedulers/background loops off unless the test proves scheduling.
- In plugin paths, move static metadata into manifest/lightweight artifacts
and keep runtime plugin loads behind explicit execution boundaries.
6. Preserve coverage shape:
- Do not delete a slow integration proof unless the exact production
composition is extracted into a named helper and tested.
@@ -57,6 +65,90 @@ coverage intact, not runner tuning by guesswork.
9. Commit with `scripts/committer "<message>" <paths...>` and push when the
user asked for commits/pushes. Stage only files touched for this attack.
## Plugin-Suite Workflow
Use this section when perf work involves bundled plugins, plugin-inspector, SDK
barrels, package-boundary tests, or extension suites.
1. Map the suite shape first:
- source tests: `pnpm test extensions/<id>` or `pnpm test:extensions:batch <id>`
- package boundaries: `pnpm run test:extensions:package-boundary:canary` and
`pnpm run test:extensions:package-boundary:compile`
- all bundled source tests: `pnpm test:extensions`
- plugin import memory: `pnpm test:extensions:memory -- --json .artifacts/test-perf/extensions-memory.json`
- plugin-inspector/report work: keep report primitives in `plugin-inspector`;
keep wrappers thin and collect peak RSS when the command supports it.
2. Start narrow, then widen:
- one plugin changed: run that plugin's tests and plugin-inspector slice.
- SDK/public barrel changed: add representative provider, channel, memory,
and feature plugins.
- loader/runtime mirror changed: add package-boundary checks and build/package
proof as needed.
- unknown shared plugin behavior: run `test:extensions:batch` groups before
`pnpm test:extensions`.
3. Treat plugin-inspector failures as product signals:
- JSON must parse.
- warnings/errors must be classified, not hidden.
- runtime capture should be quiet and config-tolerant.
- command output should include wall time, exit code, and peak RSS when
available.
4. For broad or package-heavy plugin proof, use Blacksmith Testbox by default on
maintainer machines. Warm once and reuse the same box:
- `blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`
- `blacksmith testbox run --id <ID> "OPENCLAW_TESTBOX=1 pnpm test:extensions:batch <ids>"`
- stop the box when done.
5. If plugin performance is package-artifact sensitive, switch to
`openclaw-pre-release-plugin-testing` and Package Acceptance rather than
trusting source-only timing.
## Metric Collection
Collect at least one stable metric before and after. Prefer the same machine and
same command. For Testbox comparisons, use the same `tbx_...` id when possible.
| Metric | Use for | Preferred source |
| --------------- | ---------------------------------- | --------------------------------------------------------------------------- |
| wall time | user-visible suite cost | `/usr/bin/time -l`, test wrapper duration, Testbox run time |
| Vitest duration | test body/import cost | Vitest output per file/shard |
| import duration | broad barrel/runtime loads | `OPENCLAW_VITEST_IMPORT_DURATIONS=1` |
| max RSS | memory pressure and OOM risk | `/usr/bin/time -l`, `pnpm test:extensions:memory`, wrapper memory summaries |
| CPU/user/sys | CPU-bound vs wait-bound split | `/usr/bin/time -l` locally, Testbox job timing when local CPU is noisy |
| heap snapshots | real leak vs retained module graph | `openclaw-test-heap-leaks` workflow |
Local scoped command with CPU/RSS:
```bash
timeout 240 /usr/bin/time -l pnpm test <file> --maxWorkers=1 --reporter=verbose
```
Plugin import memory profile:
```bash
pnpm build
pnpm test:extensions:memory -- --top 20 --json .artifacts/test-perf/extensions-memory.json
```
Targeted plugin import memory:
```bash
pnpm test:extensions:memory -- --extension discord --extension telegram --skip-combined
```
Heap/RSS escalation:
```bash
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
```
Use `openclaw-test-heap-leaks` when RSS keeps growing across intervals, workers
OOM, or the suspect command has app-object retention. Do not call RSS growth a
leak until snapshots or retainers support it.
## Common Root Causes
- Full bundled channel/plugin runtime loaded for static data.
@@ -64,6 +156,12 @@ coverage intact, not runner tuning by guesswork.
parser would suffice.
- Broad `api.ts`, `runtime-api.ts`, `test-api.ts`, or plugin-sdk barrels pulled
into hot tests.
- SDK root aliases or package barrels pulling focused subpaths back into a broad
plugin graph.
- Plugin-inspector loading runtime code just to render metadata, reports, or CI
policy scores.
- Bundled plugin capture reusing real config/home state instead of synthetic,
redacted, isolated state.
- Partial-real mocks using `importActual()` around broad modules.
- `vi.resetModules()` plus fresh imports in per-test loops.
- Test plugin registry seeded in `beforeAll` while runtime state resets in
@@ -72,6 +170,10 @@ coverage intact, not runner tuning by guesswork.
- Runtime/default model/auth selection paid by idle snapshots or fixtures.
- Plugin-owned media/action discovery triggered before checking whether args
contain plugin-owned fields.
- Timings missing from `test/fixtures/test-timings.unit.json`, causing hotspot
files to stay in shared workers.
- Parallel Vitest runs sharing `node_modules/.experimental-vitest-cache` without
distinct `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` values.
## Benchmark Commands
@@ -97,6 +199,25 @@ pnpm test:perf:groups --full-suite --allow-failures \
--output .artifacts/test-perf/<name>.json
```
Extension batch:
```bash
pnpm test:extensions:batch <plugin[,plugin...]> -- --reporter=verbose
```
All extension tests:
```bash
pnpm test:extensions
```
Package-boundary plugin checks:
```bash
pnpm run test:extensions:package-boundary:canary
pnpm run test:extensions:package-boundary:compile
```
Reuse an existing Vitest JSON report:
```bash
@@ -107,19 +228,26 @@ pnpm test:perf:groups --report <vitest-json> \
## Verification
- Always run the targeted test surface that proves the change.
- Run `pnpm check` before commit unless the change is docs-only and the hook
handles it.
- For source changes, run `pnpm check:changed` before push; in maintainer
Testbox mode run it in the warmed Testbox.
- For test-only changes, run `pnpm test:changed` or the exact edited tests.
- Run `pnpm build` when touching lazy-loading, bundled artifacts, package
boundaries, dynamic imports, build output, or public surfaces.
- For plugin SDK/barrel/runtime changes, add `pnpm plugin-sdk:api:check` or
`pnpm plugin-sdk:api:gen` when the API surface may drift.
- For plugin-suite perf fixes, verify at least one representative plugin batch
plus the changed gate; use Package Acceptance if the bug only exists in a
packed artifact.
- If deps are missing/stale, run `pnpm install` and retry the exact failed
command once.
- Use the report format:
```markdown
| Metric | Before | After | Gain |
| -------------- | -----: | ----: | ------------: |
| File wall time | `Xs` | `Ys` | `-Zs` (`P%`) |
| Max RSS | `XMB` | `YMB` | `-ZMB` (`P%`) |
| Metric | Before | After | Gain |
| -------------- | -----: | -----: | ------------: |
| File wall time | `Xs` | `Ys` | `-Zs` (`P%`) |
| Max RSS | `XMB` | `YMB` | `-ZMB` (`P%`) |
| CPU user/sys | `X/Ys` | `A/Bs` | explain |
```
## Handoff
@@ -127,8 +255,12 @@ pnpm test:perf:groups --report <vitest-json> \
Keep the final concise:
- Root cause.
- Suite/plugin scope.
- Files changed.
- Before/after numbers.
- Before/after wall, Vitest/import, CPU, and RSS numbers where available.
- Leak classification if memory was involved: real leak, retained module graph,
or inconclusive.
- Coverage retained.
- Verification commands.
- Testbox ID or workflow URL for remote proof.
- Commit hash and push status.

View File

@@ -1,6 +1,6 @@
interface:
display_name: "OpenClaw Test Performance"
short_description: "Benchmark and fix slow OpenClaw tests"
default_prompt: "Use $openclaw-test-performance to reassess the OpenClaw test benchmark, identify the next real hotspot, fix it without losing coverage, update the report, and commit scoped changes."
short_description: "Benchmark tests, plugin suites, CPU, RSS, and heap growth"
default_prompt: "Use $openclaw-test-performance to reassess OpenClaw test and plugin-suite performance, collect wall/import/CPU/RSS metrics, investigate memory growth when needed, fix the next real hotspot without losing coverage, update the report, and commit scoped changes."
policy:
allow_implicit_invocation: false

View File

@@ -76,6 +76,9 @@ Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
- Direct test edits run themselves. Source edits prefer explicit mappings,
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
edits are skipped by default unless they have precise mapped tests.
- Shared group-room delivery config and source-reply prompt edits are precise
mapped tests: they run the core auto-reply regressions plus Discord and Slack
delivery tests so cross-channel default changes fail before a PR push.
- Public SDK or contract edits do not automatically run every plugin test.
`check:changed` proves extension type contracts; the agent chooses the
smallest plugin/contract Vitest proof that matches the actual risk.
@@ -123,13 +126,21 @@ gh workflow run full-release-validation.yml \
--ref main \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both
-f mode=both \
-f release_profile=stable
```
Run the workflow itself from the trusted current ref, normally `--ref main`;
child workflows are dispatched from that same ref even when `ref` points at an
older release branch or tag. Full Release Validation has no separate child
workflow ref input; choose the trusted harness by choosing the workflow run ref.
Use `release_profile=minimum|stable|full` to control live/provider breadth:
`minimum` keeps the fastest OpenAI/core release-critical set, `stable` adds the
stable provider/backend set, and `full` adds the broad advisory provider/media
matrix. Do not make `full` faster by silently dropping suites; optimize setup,
artifact reuse, and sharding instead. The parent verifier job appends
slowest-job tables for child runs; rerun only that verifier after a child rerun
turns green.
If a full run is already active on a newer `origin/main`, prefer watching that
run over dispatching a duplicate. If you accidentally dispatch a stale duplicate,
@@ -198,11 +209,31 @@ gh workflow run openclaw-release-checks.yml \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=stable \
-f rerun_group=all
```
Release-check rerun groups are `all`, `install-smoke`, `cross-os`, `live-e2e`,
`package`, `qa`, `qa-parity`, and `qa-live`.
`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected
ref once as `release-package-under-test` and passes that artifact into cross-OS
release checks, release-path Docker live/E2E checks, and Package Acceptance.
When `Full Release Validation` dispatches release checks, it passes the requested
branch/tag plus an `expected_sha` so branch/tag refs resolve through the fast
remote-ref path while the package and QA jobs still validate the exact SHA.
The full-profile native live media shards use the prebuilt
`ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04` container so
`ffmpeg`/`ffprobe` are already present. If those jobs suddenly spend minutes in
dependency setup again, first check the `Live Media Runner Image` workflow and
the `Verify preinstalled live media dependencies` step before assuming the media
tests themselves slowed down.
The release Docker path intentionally shards the plugin/runtime tail. The
workflow uses `plugins-runtime-plugins`, `plugins-runtime-services`, and
`plugins-runtime-install-a` through `plugins-runtime-install-d`; aggregate
aliases such as `plugins-runtime-core`, `plugins-runtime`, and
`plugins-integrations` remain for manual reruns.
The release QA parity box is internally split into candidate and baseline lane
jobs, followed by a report job that downloads both artifacts and runs
@@ -262,12 +293,15 @@ Useful knobs:
- blank `live_model_providers`: run the full live-model provider matrix.
Release-path Docker chunks are currently `core`, `package-update-openai`,
`package-update-anthropic`, `package-update-core`, `plugins-runtime-core`,
`package-update-anthropic`, `package-update-core`,
`plugins-runtime-plugins`, `plugins-runtime-services`,
`plugins-runtime-install-a`, `plugins-runtime-install-b`,
`plugins-runtime-install-c`, `plugins-runtime-install-d`,
`bundled-channels-core`, `bundled-channels-update-a`,
`bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate
`bundled-channels` chunk remains valid for manual one-shot reruns, but release
checks use the split chunks.
`bundled-channels`, `plugins-runtime-core`, `plugins-runtime`, and
`plugins-integrations` chunks remain valid for manual one-shot reruns, but
release checks use the split chunks.
When live suites are enabled, the workflow shards broad native `pnpm test:live`
coverage through `scripts/test-live-shard.mjs` instead of one serial `live-all`
@@ -288,6 +322,8 @@ job:
- `native-live-extensions-media`
- `native-live-extensions-media-audio`
- `native-live-extensions-media-music`
- `native-live-extensions-media-music-google`
- `native-live-extensions-media-music-minimax`
- `native-live-extensions-media-video`
Use `node scripts/test-live-shard.mjs <shard> --list` to see the exact files
@@ -348,18 +384,22 @@ image. Release-path normal mode fans out into smaller Docker chunk jobs:
- `package-update-openai`
- `package-update-anthropic`
- `package-update-core`
- `plugins-runtime-core`
- `plugins-runtime-plugins`
- `plugins-runtime-services`
- `plugins-runtime-install-a`
- `plugins-runtime-install-b`
- `plugins-runtime-install-c`
- `plugins-runtime-install-d`
- `bundled-channels`
OpenWebUI is folded into `plugins-runtime-core` for full release-path coverage
and keeps a standalone `openwebui` chunk only for OpenWebUI-only dispatches.
The legacy `package-update`, `plugins-runtime`, and `plugins-integrations`
chunks still work as aggregate aliases for manual reruns, but the release
workflow uses the split chunks so provider installer checks, plugin runtime
checks, bundled plugin install/uninstall shards, and bundled-channel checks can
run on separate machines. The bundled-channel runtime-dependency coverage
OpenWebUI is folded into `plugins-runtime-services` for full release-path
coverage and keeps a standalone `openwebui` chunk only for OpenWebUI-only
dispatches. The legacy `package-update`, `plugins-runtime-core`,
`plugins-runtime`, and `plugins-integrations` chunks still work as aggregate
aliases for manual reruns, but the release workflow uses the split chunks so
provider installer checks, plugin runtime checks, bundled plugin
install/uninstall shards, and bundled-channel checks can run on separate
machines. The bundled-channel runtime-dependency coverage
inside `bundled-channels`
uses the split `bundled-channel-*` and `bundled-channel-update-*` lanes rather
than the serial `bundled-channel-deps` lane, so failures produce cheap targeted

2
.github/CODEOWNERS vendored
View File

@@ -9,6 +9,8 @@
/.github/dependabot.yml @openclaw/secops
/.github/codeql/ @openclaw/secops
/.github/workflows/codeql.yml @openclaw/secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/secops
/.github/workflows/codeql-critical-quality.yml @openclaw/secops
/src/security/ @openclaw/secops
/src/secrets/ @openclaw/secops
/src/config/*secret*.ts @openclaw/secops

View File

@@ -4,6 +4,7 @@
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404

View File

@@ -0,0 +1,53 @@
name: openclaw-codeql-agent-runtime-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/acp/control-plane
- src/agents/command
- src/agents/cli-runner
- src/agents/pi-embedded-runner
- src/agents/tools
- src/agents/*completion*.ts
- src/agents/*transport*.ts
- src/agents/model-*.ts
- src/agents/openclaw-tools*.ts
- src/agents/provider-*.ts
- src/agents/session*.ts
- src/agents/tool-call*.ts
- src/auto-reply/reply/agent-runner*.ts
- src/auto-reply/reply/commands*.ts
- src/auto-reply/reply/directive-handling*.ts
- src/auto-reply/reply/dispatch-*.ts
- src/auto-reply/reply/get-reply-run*.ts
- src/auto-reply/reply/provider-dispatcher*.ts
- src/auto-reply/reply/queue*.ts
- src/auto-reply/reply/reply-run-registry*.ts
- src/auto-reply/reply/session*.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -5,6 +5,15 @@ disable-default-queries: true
queries:
- uses: security-extended
query-filters:
# Android canvas intentionally runs trusted A2UI JavaScript; keep this profile focused on exploitable WebView edges.
- exclude:
id: java/android/websettings-javascript-enabled
# Gateway TLS already pins verified certificate SHA-256 fingerprints. OkHttp CertificatePinner pins SPKI hashes,
# so this query is noisy for OpenClaw's TOFU/local-gateway trust model and does not belong in the critical profile.
- exclude:
id: java/android/missing-certificate-pinning
paths:
- apps/android/app/src/main

View File

@@ -0,0 +1,33 @@
name: openclaw-codeql-channel-runtime-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/channels
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -0,0 +1,50 @@
name: openclaw-codeql-channel-runtime-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
paths:
- src/channels
- src/config/channel-*.ts
- src/config/types.channel*.ts
- src/gateway/server-channel*.ts
- src/gateway/server-methods/channels.ts
- src/gateway/protocol/schema/channels.ts
- src/infra/channel-*.ts
- src/infra/exec-approval-channel-runtime.ts
- src/infra/outbound/channel-*.ts
- src/plugin-sdk/channel-*.ts
- src/plugins/channel-*.ts
- src/plugins/bundled-channel-*.ts
- src/plugins/runtime/*channel*.ts
- src/secrets/channel-*.ts
- src/secrets/runtime-config-collectors-channels.ts
- src/security/audit-channel*.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -0,0 +1,33 @@
name: openclaw-codeql-config-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/config
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -0,0 +1,34 @@
name: openclaw-codeql-gateway-runtime-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/gateway/protocol
- src/gateway/server-methods
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -22,7 +22,6 @@ paths:
- src/agents/sandbox
- src/agents/sandbox.ts
- src/agents/sandbox-*.ts
- src/config
- src/cron/service/jobs.ts
- src/cron/stagger.ts
- src/gateway/*auth*.ts

View File

@@ -0,0 +1,76 @@
name: openclaw-codeql-plugin-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/plugins/activation-planner.ts
- src/plugins/api-builder.ts
- src/plugins/bundled-compat.ts
- src/plugins/bundled-dir.ts
- src/plugins/bundled-plugin-metadata.ts
- src/plugins/bundled-public-surface-runtime-root.ts
- src/plugins/bundled-runtime-deps.ts
- src/plugins/bundled-runtime-root.ts
- src/plugins/captured-registration.ts
- src/plugins/config-activation-shared.ts
- src/plugins/config-contracts.ts
- src/plugins/config-normalization-shared.ts
- src/plugins/config-policy.ts
- src/plugins/config-schema.ts
- src/plugins/config-state.ts
- src/plugins/discovery.ts
- src/plugins/effective-plugin-ids.ts
- src/plugins/externalized-bundled-plugins.ts
- src/plugins/installed-plugin-index*.ts
- src/plugins/loader*.ts
- src/plugins/manifest*.ts
- src/plugins/module-export.ts
- src/plugins/package-entrypoints.ts
- src/plugins/plugin-registry*.ts
- src/plugins/provider-contract-public-artifacts.ts
- src/plugins/provider-public-artifacts.ts
- src/plugins/public-surface*.ts
- src/plugins/registry.ts
- src/plugins/registry-types.ts
- src/plugins/runtime
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/sdk-alias.ts
- src/plugins/source-loader.ts
- src/plugins/types.ts
- src/plugins/validation-diagnostics.ts
- src/plugins/web-provider-public-artifacts*.ts
- src/plugin-sdk/*entry*.ts
- src/plugin-sdk/*facade*.ts
- src/plugin-sdk/api-baseline.ts
- src/plugin-sdk/config-schema.ts
- src/plugin-sdk/config-types.ts
- src/plugin-sdk/core.ts
- src/plugin-sdk/extension-shared.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -0,0 +1,16 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
ffmpeg \
git \
openssh-client \
unzip \
xz-utils \
zstd \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 35
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@v2
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
@@ -191,8 +191,34 @@ jobs:
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@v2
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Checkout
@@ -93,8 +93,35 @@ jobs:
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
checkout_sha: ${{ steps.checkout_ref.outputs.sha }}
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
@@ -59,6 +59,10 @@ jobs:
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_plugin_prerelease_suite: ${{ steps.manifest.outputs.run_plugin_prerelease_suite }}
plugin_prerelease_ref: ${{ steps.manifest.outputs.plugin_prerelease_ref }}
plugin_prerelease_static_matrix: ${{ steps.manifest.outputs.plugin_prerelease_static_matrix }}
plugin_prerelease_docker_lanes: ${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
@@ -124,6 +128,10 @@ jobs:
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
OPENCLAW_CI_PR_HEAD_REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
OPENCLAW_CI_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
run: |
node --input-type=module <<'EOF'
@@ -131,6 +139,9 @@ jobs:
import {
createNodeTestShards,
} from "./scripts/lib/ci-node-test-plan.mjs";
import {
assertPluginPrereleaseTestPlanComplete,
} from "./scripts/lib/plugin-prerelease-test-plan.mjs";
import {
createChannelContractTestShards,
} from "./scripts/lib/channel-contract-test-plan.mjs";
@@ -173,6 +184,16 @@ jobs:
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const runControlUiI18n =
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
const pluginPrereleasePlan = assertPluginPrereleaseTestPlanComplete();
const trustedPluginPrereleaseRef =
process.env.OPENCLAW_CI_EVENT_NAME !== "pull_request" ||
process.env.OPENCLAW_CI_PR_HEAD_REPOSITORY === process.env.OPENCLAW_CI_REPOSITORY;
const pluginPrereleaseRef =
process.env.OPENCLAW_CI_EVENT_NAME === "pull_request" && trustedPluginPrereleaseRef
? process.env.OPENCLAW_CI_PR_HEAD_SHA
: process.env.OPENCLAW_CI_CHECKOUT_REVISION;
const runPluginPrereleaseSuite =
runNodeFull && isCanonicalRepository && trustedPluginPrereleaseRef;
const extensionTestShardCount = isCanonicalRepository
? DEFAULT_EXTENSION_TEST_SHARD_COUNT
: Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36);
@@ -264,6 +285,20 @@ jobs:
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_plugin_prerelease_suite: runPluginPrereleaseSuite,
plugin_prerelease_ref: runPluginPrereleaseSuite ? pluginPrereleaseRef : "",
plugin_prerelease_static_matrix: createMatrix(
runPluginPrereleaseSuite
? pluginPrereleasePlan.staticChecks.map((check) => ({
check_name: check.checkName,
command: check.command,
task: check.check,
}))
: [],
),
plugin_prerelease_docker_lanes: runPluginPrereleaseSuite
? pluginPrereleasePlan.dockerLanes.join(" ")
: "",
run_build_smoke: runNodeFull,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
@@ -468,7 +503,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -540,7 +575,7 @@ jobs:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_sha }}
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -669,7 +704,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -764,7 +799,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -867,7 +902,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -935,7 +970,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1055,7 +1090,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1135,7 +1170,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1322,7 +1357,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1454,7 +1489,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1621,6 +1656,91 @@ jobs:
exit 1
fi
plugin-prerelease-static-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_static_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run plugin prerelease static shard
env:
PLUGIN_PRERELEASE_COMMAND: ${{ matrix.command }}
PLUGIN_PRERELEASE_TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
echo "Running ${PLUGIN_PRERELEASE_TASK}: ${PLUGIN_PRERELEASE_COMMAND}"
bash -c "$PLUGIN_PRERELEASE_COMMAND"
plugin-prerelease-docker-suite:
name: plugin-prerelease-docker-suite
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true'
permissions:
actions: read
contents: read
packages: write
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.preflight.outputs.plugin_prerelease_ref }}
include_repo_e2e: false
include_release_path_suites: false
include_openwebui: false
docker_lanes: ${{ needs.preflight.outputs.plugin_prerelease_docker_lanes }}
include_live_suites: false
live_models_only: false
plugin-prerelease-suite:
permissions:
contents: read
name: plugin-prerelease-suite
needs: [preflight, plugin-prerelease-static-shard, plugin-prerelease-docker-suite]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_prerelease_suite == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin prerelease suite
env:
DOCKER_RESULT: ${{ needs.plugin-prerelease-docker-suite.result }}
STATIC_RESULT: ${{ needs.plugin-prerelease-static-shard.result }}
shell: bash
run: |
set -euo pipefail
failed=0
for result in \
"plugin-prerelease-static=${STATIC_RESULT}" \
"plugin-prerelease-docker=${DOCKER_RESULT}"
do
name="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${name} ended with ${status}"
failed=1
fi
done
exit "$failed"
build-smoke:
permissions:
contents: read
@@ -1652,7 +1772,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1715,7 +1835,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1758,7 +1878,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1863,7 +1983,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1904,7 +2024,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -2005,7 +2125,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

View File

@@ -0,0 +1,102 @@
name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
push:
branches: [main]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
steps:
- name: Debounce bursty metadata events
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
- name: Dispatch exact ClawSweeper review
if: ${{ github.event_name != 'push' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
SOURCE_EVENT: ${{ github.event_name }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
--arg source_event "$SOURCE_EVENT" \
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper review."
else
echo "::warning::Skipping ClawSweeper dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi
- name: Dispatch ClawSweeper commit review
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.deleted != true }}
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref }}
CREATE_CHECKS: ${{ vars.CLAWSWEEPER_COMMIT_REVIEW_CREATE_CHECKS || 'false' }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper commit dispatch because no dispatch credential is configured."
exit 0
fi
case "$CREATE_CHECKS" in
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
*) create_checks=false ;;
esac
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--arg before_sha "$BEFORE_SHA" \
--arg after_sha "$AFTER_SHA" \
--arg ref "$SOURCE_REF" \
--argjson create_checks "$create_checks" \
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper commit review."
else
echo "::warning::Skipping ClawSweeper commit dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi

View File

@@ -0,0 +1,51 @@
name: CodeQL Android Critical Security
on:
workflow_dispatch:
schedule:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
android:
name: Critical Security (android)
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
config-file: ./.github/codeql/codeql-android-critical-security.yml
- name: Build Android for CodeQL
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -0,0 +1,145 @@
name: CodeQL Critical Quality
on:
workflow_dispatch:
schedule:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
javascript-typescript:
name: Critical Quality (javascript-typescript)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-javascript-typescript-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/javascript-typescript"
config-boundary:
name: Critical Quality (config-boundary)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/config-boundary"
gateway-runtime-boundary:
name: Critical Quality (gateway-runtime-boundary)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/gateway-runtime-boundary"
channel-runtime-boundary:
name: Critical Quality (channel-runtime-boundary)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/channel-runtime-boundary"
agent-runtime-boundary:
name: Critical Quality (agent-runtime-boundary)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/agent-runtime-boundary"
plugin-boundary:
name: Critical Quality (plugin-boundary)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/plugin-boundary"

View File

@@ -0,0 +1,89 @@
name: CodeQL macOS Critical Security
on:
workflow_dispatch:
schedule:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
macos:
name: Critical Security (macOS)
runs-on: blacksmith-6vcpu-macos-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: swift
build-mode: manual
config-file: ./.github/codeql/codeql-macos-critical-security.yml
- name: Build macOS for CodeQL
run: swift build --package-path apps/macos --product OpenClaw
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
category: "/codeql-critical-security/macos"
- name: Remove dependency build results
env:
SARIF_OUTPUT: sarif-results
run: |
set -euo pipefail
shopt -s nullglob
if [ ! -d "$SARIF_OUTPUT" ]; then
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
exit 1
fi
mkdir -p sarif-results-filtered
files=("$SARIF_OUTPUT"/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then
echo "No SARIF files found in $SARIF_OUTPUT" >&2
exit 1
fi
for file in "${files[@]}"; do
jq '
def in_dependency_build:
((.locations // []) | length > 0)
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
' "$file" > "sarif-results-filtered/$(basename "$file")"
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -4,16 +4,13 @@ on:
workflow_dispatch:
inputs:
profile:
description: CodeQL profile to run
description: CodeQL security profile to run
required: false
default: all
type: choice
options:
- all
- security
- quality
- android-security
- macos-security
schedule:
- cron: "0 6 * * *"
@@ -31,7 +28,7 @@ permissions:
jobs:
critical-security:
name: Critical Security (${{ matrix.language }})
name: Critical Security (${{ matrix.category }})
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'security' }}
runs-on: ${{ matrix.runs_on }}
timeout-minutes: ${{ matrix.timeout_minutes }}
@@ -40,10 +37,17 @@ jobs:
matrix:
include:
- language: javascript-typescript
category: javascript-typescript
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-javascript-typescript-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: actions
category: actions
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
@@ -62,130 +66,4 @@ jobs:
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/${{ matrix.language }}"
critical-quality:
name: Critical Quality (javascript-typescript)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'quality' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-javascript-typescript-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/javascript-typescript"
android-security:
name: Critical Security (android)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'android-security' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
config-file: ./.github/codeql/codeql-android-critical-security.yml
- name: Build Android for CodeQL
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"
macos-security:
name: Critical Security (macOS)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'macos-security' }}
runs-on: blacksmith-6vcpu-macos-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: swift
build-mode: manual
config-file: ./.github/codeql/codeql-macos-critical-security.yml
- name: Build macOS for CodeQL
run: swift build --package-path apps/macos --product OpenClaw
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
category: "/codeql-critical-security/macos"
- name: Remove dependency build results
env:
SARIF_OUTPUT: sarif-results
run: |
set -euo pipefail
shopt -s nullglob
if [ ! -d "$SARIF_OUTPUT" ]; then
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
exit 1
fi
mkdir -p sarif-results-filtered
files=("$SARIF_OUTPUT"/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then
echo "No SARIF files found in $SARIF_OUTPUT" >&2
exit 1
fi
for file in "${files[@]}"; do
jq '
def in_dependency_build:
((.locations // []) | length > 0)
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
' "$file" > "sarif-results-filtered/$(basename "$file")"
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"
category: "/codeql-critical-security/${{ matrix.category }}"

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -26,6 +26,15 @@ on:
- fresh
- upgrade
- both
release_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
rerun_group:
description: Validation group to run
required: false
@@ -73,7 +82,7 @@ permissions:
concurrency:
group: full-release-validation-${{ inputs.ref }}
cancel-in-progress: false
cancel-in-progress: ${{ inputs.ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -87,17 +96,23 @@ jobs:
outputs:
sha: ${{ steps.resolve.outputs.sha }}
steps:
- name: Checkout target ref
- name: Checkout trusted workflow helper
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
ref: ${{ github.ref_name }}
path: workflow
fetch-depth: 1
persist-credentials: false
submodules: false
- name: Resolve target SHA
id: resolve
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
env:
TARGET_REF: ${{ inputs.ref }}
run: |
bash workflow/scripts/github/resolve-openclaw-ref.sh \
--ref "$TARGET_REF" \
--github-output "$GITHUB_OUTPUT"
- name: Summarize target
env:
@@ -192,6 +207,19 @@ jobs:
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
cleanup_child_run() {
local exit_code=$?
trap - EXIT INT TERM
local child_status
child_status="$(gh run view "$run_id" --json status --jq '.status' 2>/dev/null || true)"
if [[ "$child_status" != "completed" ]]; then
echo "Cancelling child ${workflow} run ${run_id} after parent exit (${exit_code})."
gh run cancel "$run_id" || gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/force-cancel" || true
fi
return "$exit_code"
}
trap cleanup_child_run EXIT INT TERM
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
@@ -199,6 +227,7 @@ jobs:
fi
sleep 30
done
trap - EXIT INT TERM
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
@@ -217,6 +246,23 @@ jobs:
echo "- Target SHA: \`${TARGET_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
cancel_same_sha_push_ci() {
local run_ids run_id
run_ids="$(
gh run list --workflow ci.yml --limit 100 --json databaseId,event,headSha,status \
--jq 'map(select(.event == "push" and .headSha == env.TARGET_SHA and (.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending"))) | .[].databaseId'
)"
if [[ -z "${run_ids// }" ]]; then
return 0
fi
while IFS= read -r run_id; do
[[ -n "${run_id// }" ]] || continue
echo "Cancelling same-SHA push CI run ${run_id}; Full Release Validation dispatches the full manual CI child for ${TARGET_SHA}."
gh run cancel "$run_id" || gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/force-cancel" || true
done <<< "$run_ids"
}
cancel_same_sha_push_ci
dispatch_and_wait ci.yml -f target_ref="$TARGET_SHA"
release_checks:
@@ -239,6 +285,7 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PROVIDER: ${{ inputs.provider }}
MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RERUN_GROUP: ${{ inputs.rerun_group }}
run: |
set -euo pipefail
@@ -279,6 +326,19 @@ jobs:
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
cleanup_child_run() {
local exit_code=$?
trap - EXIT INT TERM
local child_status
child_status="$(gh run view "$run_id" --json status --jq '.status' 2>/dev/null || true)"
if [[ "$child_status" != "completed" ]]; then
echo "Cancelling child ${workflow} run ${run_id} after parent exit (${exit_code})."
gh run cancel "$run_id" || gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/force-cancel" || true
fi
return "$exit_code"
}
trap cleanup_child_run EXIT INT TERM
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
@@ -286,6 +346,7 @@ jobs:
fi
sleep 30
done
trap - EXIT INT TERM
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
@@ -304,6 +365,7 @@ jobs:
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Provider: \`${PROVIDER}\`"
echo "- Cross-OS mode: \`${MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Rerun group: \`${RERUN_GROUP}\`"
} >> "$GITHUB_STEP_SUMMARY"
@@ -314,8 +376,10 @@ jobs:
dispatch_and_wait openclaw-release-checks.yml \
-f ref="$TARGET_SHA" \
-f expected_sha="$TARGET_SHA" \
-f provider="$PROVIDER" \
-f mode="$MODE" \
-f release_profile="$RELEASE_PROFILE" \
-f rerun_group="$child_rerun_group"
npm_telegram:
@@ -370,6 +434,19 @@ jobs:
echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
cleanup_child_run() {
local exit_code=$?
trap - EXIT INT TERM
local child_status
child_status="$(gh run view "$run_id" --json status --jq '.status' 2>/dev/null || true)"
if [[ "$child_status" != "completed" ]]; then
echo "Cancelling npm-telegram-beta-e2e.yml child run ${run_id} after parent exit (${exit_code})."
gh run cancel "$run_id" || gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/force-cancel" || true
fi
return "$exit_code"
}
trap cleanup_child_run EXIT INT TERM
while true; do
status="$(gh run view "$run_id" --json status --jq '.status')"
if [[ "$status" == "completed" ]]; then
@@ -377,6 +454,7 @@ jobs:
fi
sleep 30
done
trap - EXIT INT TERM
conclusion="$(gh run view "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view "$run_id" --json url --jq '.url')"
@@ -490,6 +568,34 @@ jobs:
fi
}
summarize_child_timing() {
local label="$1"
local run_id="$2"
if [[ -z "${run_id// }" ]]; then
return 0
fi
{
echo
echo "### Slowest jobs: ${label}"
echo
gh run view "$run_id" --json jobs --jq '
def ts: fromdateiso8601;
"| Job | Result | Minutes |",
"| --- | --- | ---: |",
([.jobs[]
| select(.startedAt != "0001-01-01T00:00:00Z" and .completedAt != "0001-01-01T00:00:00Z")
| . + {durationMin: ((((.completedAt | ts) - (.startedAt | ts)) / 60) * 10 | round / 10)}
| {name, conclusion, durationMin}]
| sort_by(.durationMin)
| reverse
| .[0:10]
| map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.durationMin | tostring) + " |")
| .[])
' || echo "_Unable to summarize jobs for run ${run_id}._"
} >> "$GITHUB_STEP_SUMMARY"
}
failed=0
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
@@ -510,4 +616,8 @@ jobs:
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
fi
summarize_child_timing "normal_ci" "$NORMAL_CI_RUN_ID"
summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
exit "$failed"

View File

@@ -104,6 +104,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
@@ -211,6 +213,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
@@ -361,6 +365,8 @@ jobs:
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Setup Node environment for package smoke
uses: ./.github/actions/setup-node-env

View File

@@ -278,6 +278,7 @@ jobs:
const labelColor = "B60205";
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
const authorLogin = pullRequest.user?.login;
const headRefName = pullRequest.head?.ref ?? "";
if (!authorLogin) {
return;
}
@@ -374,7 +375,10 @@ jobs:
return false;
};
if (await isPrivilegedAuthor()) {
const isClownfishPullRequest =
typeof headRefName === "string" && headRefName.startsWith("clownfish/");
if ((await isPrivilegedAuthor()) || isClownfishPullRequest) {
if (labelNames.has(activePrLimitLabel)) {
try {
await github.rest.issues.removeLabel({

View File

@@ -0,0 +1,54 @@
name: Live Media Runner Image
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/images/live-media-runner/Dockerfile"
- ".github/workflows/live-media-runner-image.yml"
permissions:
contents: read
packages: write
concurrency:
group: live-media-runner-image-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build:
name: Build live media runner image
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build and push live media runner image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .github/images/live-media-runner
file: .github/images/live-media-runner/Dockerfile
platforms: linux/amd64
tags: |
ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
ghcr.io/openclaw/openclaw-live-media-runner:${{ github.sha }}
sbom: true
provenance: mode=max
push: true

View File

@@ -51,6 +51,31 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
required: false
default: ""
type: string
candidate_file_name:
description: Optional candidate tarball file name inside candidate_artifact_name
required: false
default: ""
type: string
candidate_version:
description: Optional candidate OpenClaw package version
required: false
default: ""
type: string
candidate_source_sha:
description: Optional source SHA used to build the candidate tarball
required: false
default: ""
type: string
workflow_call:
inputs:
ref:
@@ -90,6 +115,31 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
required: false
default: ""
type: string
candidate_file_name:
description: Optional candidate tarball file name inside candidate_artifact_name
required: false
default: ""
type: string
candidate_version:
description: Optional candidate OpenClaw package version
required: false
default: ""
type: string
candidate_source_sha:
description: Optional source SHA used to build the candidate tarball
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -108,7 +158,7 @@ permissions: read-all
concurrency:
group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }}
cancel-in-progress: false
cancel-in-progress: ${{ inputs.ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -119,7 +169,7 @@ env:
jobs:
prepare:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -260,6 +310,7 @@ jobs:
persist-credentials: false
- name: Checkout public source ref
if: inputs.candidate_artifact_name == ''
uses: actions/checkout@v6
with:
repository: ${{ env.OPENCLAW_REPOSITORY }}
@@ -270,7 +321,7 @@ jobs:
submodules: recursive
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -280,9 +331,13 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: source/pnpm-lock.yaml
cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
- name: Ensure pnpm store cache directory exists
run: mkdir -p "$(pnpm store path --silent)"
- name: Build candidate artifact once
if: inputs.candidate_artifact_name == ''
env:
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare
run: |
@@ -291,6 +346,52 @@ jobs:
--source-dir source \
--output-dir "${OUTPUT_DIR}"
- name: Download provided candidate artifact
if: inputs.candidate_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
run-id: ${{ inputs.candidate_artifact_run_id || github.run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
- name: Capture provided candidate artifact metadata
if: inputs.candidate_artifact_name != ''
env:
PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
INPUT_CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }}
INPUT_CANDIDATE_VERSION: ${{ inputs.candidate_version }}
INPUT_CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }}
CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json
run: |
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const packageDir = process.env.PACKAGE_DIR;
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
const candidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!candidateFileName) {
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
}
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
}
const candidateVersion = process.env.INPUT_CANDIDATE_VERSION.trim();
if (!candidateVersion) {
throw new Error("candidate_version is required when candidate_artifact_name is provided.");
}
const sourceSha = process.env.INPUT_CANDIDATE_SOURCE_SHA.trim();
if (!/^[0-9a-f]{40}$/iu.test(sourceSha)) {
throw new Error("candidate_source_sha must be a full commit SHA when candidate_artifact_name is provided.");
}
fs.writeFileSync(
process.env.CANDIDATE_JSON,
`${JSON.stringify({ candidateFileName, candidateVersion, sourceSha }, null, 2)}\n`,
);
NODE
- name: Resolve baseline package spec
if: ${{ inputs.mode != 'fresh' }}
id: baseline
@@ -398,7 +499,7 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
version: ${{ env.PNPM_VERSION }}
run_install: false

View File

@@ -63,6 +63,15 @@ on:
required: false
default: ""
type: string
release_test_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
workflow_call:
inputs:
ref:
@@ -124,6 +133,11 @@ on:
required: false
default: ""
type: string
release_test_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -139,6 +153,8 @@ on:
required: false
CEREBRAS_API_KEY:
required: false
DEEPINFRA_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
@@ -441,24 +457,36 @@ jobs:
- chunk_id: package-update-core
label: package/update core
timeout_minutes: 120
- chunk_id: plugins-runtime-core
label: plugins/runtime core
timeout_minutes: 180
- chunk_id: plugins-runtime-plugins
label: plugins/runtime plugins
timeout_minutes: 120
- chunk_id: plugins-runtime-services
label: plugins/runtime services
timeout_minutes: 120
- chunk_id: plugins-runtime-install-a
label: plugins/runtime install A
timeout_minutes: 180
timeout_minutes: 120
- chunk_id: plugins-runtime-install-b
label: plugins/runtime install B
timeout_minutes: 180
timeout_minutes: 120
- chunk_id: plugins-runtime-install-c
label: plugins/runtime install C
timeout_minutes: 120
- chunk_id: plugins-runtime-install-d
label: plugins/runtime install D
timeout_minutes: 120
- chunk_id: bundled-channels-core
label: bundled channels core
timeout_minutes: 90
- chunk_id: bundled-channels-update-a
label: bundled channels update A
timeout_minutes: 90
timeout_minutes: 45
- chunk_id: bundled-channels-update-discord
label: bundled channels update Discord
timeout_minutes: 30
- chunk_id: bundled-channels-update-b
label: bundled channels update B
timeout_minutes: 90
timeout_minutes: 45
- chunk_id: bundled-channels-contracts
label: bundled channels contracts
timeout_minutes: 90
@@ -470,6 +498,7 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -511,6 +540,7 @@ jobs:
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
@@ -523,6 +553,13 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -559,8 +596,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/release-${CHUNK}-plan.json"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
@@ -575,14 +612,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
shell: bash
@@ -616,7 +653,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
pnpm test:docker:all
node .release-harness/scripts/test-docker-all.mjs
- name: Summarize Docker E2E chunk
if: always()
@@ -628,7 +665,7 @@ jobs:
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Docker E2E chunk artifacts
if: always()
@@ -683,6 +720,7 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -724,6 +762,7 @@ jobs:
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
@@ -736,6 +775,13 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -757,7 +803,7 @@ jobs:
id: plan
shell: bash
env:
LANES: ${{ inputs.docker_lanes }}
LANES: ${{ matrix.group.docker_lanes }}
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
run: |
set -euo pipefail
@@ -771,8 +817,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/targeted-plan.json"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
suffix="$(printf '%s' "$LANES" | tr ',[:space:]' '-' | tr -cd 'A-Za-z0-9._-' | sed -E 's/-+/-/g; s/^-//; s/-$//')"
echo "artifact_suffix=${suffix:-targeted}" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
@@ -789,14 +835,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
shell: bash
@@ -828,11 +874,11 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
pnpm test:docker:live-build
OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
export OPENCLAW_DOCKER_ALL_BUILD=0
pnpm test:docker:all
node .release-harness/scripts/test-docker-all.mjs
- name: Summarize targeted Docker E2E lanes
if: always()
@@ -844,7 +890,7 @@ jobs:
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
- name: Upload targeted Docker E2E artifacts
if: always()
@@ -866,6 +912,7 @@ jobs:
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
@@ -876,6 +923,13 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -910,8 +964,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI=true
plan_path=".artifacts/docker-tests/release-openwebui-plan.json"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
@@ -926,14 +980,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Run Open WebUI Docker E2E chunk
shell: bash
@@ -949,7 +1003,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-openwebui-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
pnpm test:docker:all
node .release-harness/scripts/test-docker-all.mjs
- name: Summarize Open WebUI Docker E2E chunk
if: always()
@@ -961,7 +1015,7 @@ jobs:
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Open WebUI Docker E2E artifacts
if: always()
@@ -992,6 +1046,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -999,6 +1054,13 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Plan Docker E2E images
id: plan
shell: bash
@@ -1021,8 +1083,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/plan.json"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
@@ -1167,6 +1229,8 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
with:
max-cache-size-mb: 800000
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
@@ -1208,22 +1272,31 @@ jobs:
include:
- provider_label: Anthropic
providers: anthropic
profiles: stable full
- provider_label: Google
providers: google
profiles: stable full
- provider_label: MiniMax
providers: minimax
profiles: stable full
- provider_label: OpenAI
providers: openai
profiles: minimum stable full
- provider_label: OpenCode
providers: opencode-go
profiles: full
- provider_label: OpenRouter
providers: openrouter
profiles: full
- provider_label: xAI
providers: xai
profiles: full
- provider_label: Z.ai
providers: zai
profiles: full
- provider_label: Fireworks
providers: fireworks
profiles: full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1232,6 +1305,7 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1261,12 +1335,22 @@ jobs:
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live Docker harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
@@ -1274,9 +1358,11 @@ jobs:
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Validate provider credential
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
@@ -1311,7 +1397,8 @@ jobs:
esac
- name: Run Docker live model sweep
run: pnpm test:docker:live-models
if: contains(matrix.profiles, inputs.release_test_profile)
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-models-docker.sh
validate_live_models_docker_targeted:
name: Docker live models (selected providers)
@@ -1327,6 +1414,7 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1361,6 +1449,13 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live Docker harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -1468,7 +1563,7 @@ jobs:
done
- name: Run Docker live model sweep
run: pnpm test:docker:live-models
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-models-docker.sh
validate_live_provider_suites:
needs: validate_selected_ref
@@ -1481,160 +1576,142 @@ jobs:
include:
- suite_id: native-live-src-agents
label: Native live agents
command: node scripts/test-live-shard.mjs native-live-src-agents
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-agents
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: node scripts/test-live-shard.mjs native-live-src-gateway-core
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-google
label: Native live gateway profiles Google
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-openai
label: Native live gateway profiles OpenAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-fireworks
label: Native live gateway profiles Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=fireworks node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=fireworks node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-deepseek
label: Native live gateway profiles DeepSeek
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go
label: Native live gateway profiles OpenCode Go
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go node scripts/test-live-shard.mjs native-live-src-gateway-profiles
label: Native live gateway profiles OpenCode Go deep
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go-smoke
label: Native live gateway profiles OpenCode Go smoke
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go OPENCLAW_LIVE_GATEWAY_SMOKE=1 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 45
profile_env_only: false
profiles: stable
- suite_id: native-live-src-gateway-profiles-openrouter
label: Native live gateway profiles OpenRouter
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openrouter node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openrouter node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-xai
label: Native live gateway profiles xAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-zai
label: Native live gateway profiles Z.ai
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=zai node scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=zai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-backends
label: Native live gateway backends
command: node scripts/test-live-shard.mjs native-live-src-gateway-backends
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-test
label: Native live test harnesses
command: node scripts/test-live-shard.mjs native-live-test
command: node .release-harness/scripts/test-live-shard.mjs native-live-test
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
- suite_id: native-live-extensions-a-k
label: Native live plugins A-K
command: node scripts/test-live-shard.mjs native-live-extensions-a-k
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: stable full
- suite_id: native-live-extensions-l-n
label: Native live plugins L-N
command: node scripts/test-live-shard.mjs native-live-extensions-l-n
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-l-n
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-openai
label: Native live OpenAI plugin
command: node scripts/test-live-shard.mjs native-live-extensions-openai
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-openai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-extensions-o-z-other
label: Native live plugins O-Z other
command: node scripts/test-live-shard.mjs native-live-extensions-o-z-other
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-o-z-other
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-xai
label: Native live xAI plugin
command: node scripts/test-live-shard.mjs native-live-extensions-xai
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-xai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
- suite_id: native-live-extensions-media-audio
label: Native live media audio plugins
command: node scripts/test-live-shard.mjs native-live-extensions-media-audio
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
- suite_id: native-live-extensions-media-music
label: Native live media music plugins
command: node scripts/test-live-shard.mjs native-live-extensions-media-music
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
- suite_id: native-live-extensions-media-video
label: Native live media video plugins
command: node scripts/test-live-shard.mjs native-live-extensions-media-video
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: live-gateway-docker
label: Docker live gateway
command: pnpm test:docker:live-gateway
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: pnpm test:docker:live-cli-backend
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-cli-backend-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: pnpm test:docker:live-acp-bind
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-acp-bind-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: pnpm test:docker:live-codex-harness
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-codex-harness-docker.sh
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1643,6 +1720,7 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1685,12 +1763,22 @@ jobs:
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live shard harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
@@ -1698,28 +1786,11 @@ jobs:
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Install live media dependencies
if: matrix.needs_ffmpeg
shell: bash
run: |
set -euo pipefail
if ! command -v ffmpeg >/dev/null 2>&1; then
for attempt in 1 2 3; do
if sudo apt-get update -o Acquire::Retries=3; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 5))
done
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg
fi
ffmpeg -version | head -1
- name: Configure suite-specific env
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
@@ -1729,22 +1800,25 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5" >> "$GITHUB_ENV"
# The CLI backend Docker lane should exercise the same staged
# Codex auth path Peter uses locally so MCP cron creation and
# multimodal probes stay covered in CI. Replace the staged
# config.toml with a minimal CI-safe config so the repo stays
# trusted for MCP/tool use without inheriting maintainer-local
# provider/profile overrides that do not exist inside CI.
# Keep the release-blocking CI lane on Codex API-key auth. The
# staged auth-file path remains supported for local maintainer
# reruns, but it can hang on stale subscription/session state in
# an otherwise healthy release run.
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
# Replace the staged config.toml with a minimal CI-safe config so
# the repo stays trusted for MCP/tool use without inheriting
# maintainer-local provider/profile overrides that do not exist
# inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
@@ -1752,6 +1826,9 @@ jobs:
# is currently stale, but the wrapper still supports codex-auth for
# local maintainer reruns without changing Peter's flow.
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CODEX_HARNESS_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
;;
live-acp-bind-docker)
if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then
@@ -1766,4 +1843,148 @@ jobs:
esac
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile)
run: ${{ matrix.command }}
validate_live_media_provider_suites:
name: Live media suites (${{ matrix.label }})
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only
runs-on: blacksmith-32vcpu-ubuntu-2404
container:
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
credentials:
username: ${{ github.actor }}
password: ${{ github.token }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: native-live-extensions-a-k
label: Native live plugins A-K
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-a-k
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-audio
label: Native live media audio plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-audio
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-google
label: Native live media music Google
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=google node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-google
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-minimax
label: Native live media music MiniMax
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=minimax node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-minimax
timeout_minutes: 90
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-video
label: Native live media video plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-video
timeout_minutes: 90
profile_env_only: false
profiles: full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: ""
OPENCLAW_LIVE_VYDRA_VIDEO: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live shard harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Verify preinstalled live media dependencies
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
ffmpeg -version | head -1
ffprobe -version | head -1
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Configure suite-specific env
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.profile_env_only }}" == "true" ]]; then
echo "OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1" >> "$GITHUB_ENV"
fi
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile)
run: ${{ matrix.command }}

View File

@@ -7,6 +7,11 @@ on:
description: Branch, tag, or full commit SHA to validate
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
provider:
description: Provider lane for cross-OS onboarding and the end-to-end agent turn
required: false
@@ -25,6 +30,15 @@ on:
- fresh
- upgrade
- both
release_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
rerun_group:
description: Release check group to run
required: false
@@ -42,25 +56,26 @@ on:
concurrency:
group: openclaw-release-checks-${{ inputs.ref }}
cancel-in-progress: false
cancel-in-progress: ${{ inputs.ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
jobs:
resolve_target:
runs-on: blacksmith-32vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
outputs:
ref: ${{ steps.inputs.outputs.ref }}
sha: ${{ steps.ref.outputs.sha }}
revision: ${{ steps.ref.outputs.sha }}
provider: ${{ steps.inputs.outputs.provider }}
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
steps:
- name: Require main or release workflow ref for release checks
@@ -76,24 +91,56 @@ jobs:
- name: Validate ref input
env:
RELEASE_REF: ${{ inputs.ref }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_REF// }" ]] || [[ "${RELEASE_REF}" == -* ]]; then
echo "Expected a branch, tag, or full commit SHA; got: ${RELEASE_REF}" >&2
exit 1
fi
if [[ -n "${EXPECTED_SHA// }" ]] && [[ ! "${EXPECTED_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Expected expected_sha to be a full commit SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
- name: Checkout selected ref
- name: Checkout trusted workflow helper
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref_name }}
path: workflow
fetch-depth: 1
- name: Fast-resolve selected ref
id: fast_ref
env:
RELEASE_REF: ${{ inputs.ref }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
bash workflow/scripts/github/resolve-openclaw-ref.sh \
--ref "$RELEASE_REF" \
--expected-sha "$EXPECTED_SHA" \
--fallback-ok \
--github-output "$GITHUB_OUTPUT"
- name: Checkout selected ref for reachability fallback
if: steps.fast_ref.outputs.fallback == 'true'
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
path: source
fetch-depth: 0
- name: Resolve checked-out SHA
id: ref
- name: Resolve checked-out fallback SHA
if: steps.fast_ref.outputs.fallback == 'true'
id: fallback_ref
working-directory: source
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate selected ref belongs to this repository
if: steps.fast_ref.outputs.fallback == 'true'
working-directory: source
env:
RELEASE_REF: ${{ inputs.ref }}
run: |
@@ -114,12 +161,36 @@ jobs:
echo "Secret-bearing release checks only run repository-owned branch/tag history, not arbitrary unreferenced commits." >&2
exit 1
- name: Finalize resolved SHA
id: ref
env:
FAST_SHA: ${{ steps.fast_ref.outputs.sha }}
FALLBACK_SHA: ${{ steps.fallback_ref.outputs.sha }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
USED_FALLBACK: ${{ steps.fast_ref.outputs.fallback }}
run: |
set -euo pipefail
selected_sha="$FAST_SHA"
if [[ "$USED_FALLBACK" == "true" ]]; then
selected_sha="$FALLBACK_SHA"
fi
if [[ -z "$selected_sha" ]]; then
echo "Failed to resolve selected ref SHA." >&2
exit 1
fi
if [[ -n "${EXPECTED_SHA// }" ]] && [[ "${selected_sha,,}" != "${EXPECTED_SHA,,}" ]]; then
echo "Ref resolved to ${selected_sha}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
- name: Capture selected inputs
id: inputs
env:
RELEASE_REF_INPUT: ${{ inputs.ref }}
RELEASE_PROVIDER_INPUT: ${{ inputs.provider }}
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
run: |
set -euo pipefail
@@ -127,6 +198,7 @@ jobs:
printf 'ref=%s\n' "$RELEASE_REF_INPUT"
printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT"
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$RELEASE_PROFILE_INPUT"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
} >> "$GITHUB_OUTPUT"
@@ -134,8 +206,10 @@ jobs:
env:
RELEASE_REF: ${{ inputs.ref }}
RELEASE_SHA: ${{ steps.ref.outputs.sha }}
RELEASE_REF_FAST_PATH: ${{ steps.fast_ref.outputs.fast }}
RELEASE_PROVIDER: ${{ inputs.provider }}
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
run: |
{
@@ -143,12 +217,83 @@ jobs:
echo
echo "- Requested ref: \`${RELEASE_REF}\`"
echo "- Validated SHA: \`${RELEASE_SHA}\`"
echo "- Ref resolution fast path: \`${RELEASE_REF_FAST_PATH}\`"
echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`"
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
echo "- This run will execute cross-OS release validation, install smoke, QA Lab parity, Matrix, and Telegram lanes, and the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
} >> "$GITHUB_STEP_SUMMARY"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target]
if: contains(fromJSON('["all","cross-os","live-e2e","package"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
package_sha256: ${{ steps.package.outputs.sha256 }}
package_version: ${{ steps.package.outputs.package_version }}
source_sha: ${{ steps.package.outputs.source_sha }}
steps:
- name: Checkout trusted workflow ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set artifact metadata
id: artifact
run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
install-deps: "false"
- name: Resolve release package artifact
id: package
shell: bash
env:
PACKAGE_REF: ${{ needs.resolve_target.outputs.revision }}
run: |
set -euo pipefail
node scripts/resolve-openclaw-package-candidate.mjs \
--source ref \
--package-ref "$PACKAGE_REF" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")"
version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")"
source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
{
echo "## Release package artifact"
echo
echo "- Artifact: \`release-package-under-test\`"
echo "- Package ref: \`$PACKAGE_REF\`"
echo "- SHA-256: \`$digest\`"
echo "- Version: \`$version\`"
echo "- Source SHA: \`$source_sha\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release package artifact
uses: actions/upload-artifact@v7
with:
name: release-package-under-test
path: .artifacts/docker-e2e-package/openclaw-current.tgz
retention-days: 14
if-no-files-found: error
install_smoke_release_checks:
needs: [resolve_target]
if: contains(fromJSON('["all","install-smoke"]'), needs.resolve_target.outputs.rerun_group)
@@ -156,18 +301,23 @@ jobs:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
ref: ${{ needs.resolve_target.outputs.revision }}
run_bun_global_install_smoke: true
cross_os_release_checks:
needs: [resolve_target]
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","cross-os"]'), needs.resolve_target.outputs.rerun_group)
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
ref: ${{ needs.resolve_target.outputs.revision }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
candidate_artifact_run_id: ${{ github.run_id }}
candidate_file_name: openclaw-current.tgz
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -176,7 +326,8 @@ jobs:
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
live_and_e2e_release_checks:
live_repo_e2e_release_checks:
name: Run repo/live E2E validation
needs: [resolve_target]
if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group)
permissions:
@@ -186,12 +337,13 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: true
include_release_path_suites: true
include_openwebui: true
include_release_path_suites: false
include_openwebui: false
include_live_suites: true
secrets:
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
secrets: &live_e2e_release_secrets
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -199,6 +351,7 @@ jobs:
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -237,9 +390,30 @@ jobs:
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
docker_e2e_release_checks:
name: Run Docker release-path validation
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group)
permissions:
actions: read
contents: read
packages: write
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: false
include_release_path_suites: true
include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'minimum' }}
include_live_suites: false
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_artifact_run_id: ${{ github.run_id }}
secrets: *live_e2e_release_secrets
package_acceptance_release_checks:
name: Run package acceptance
needs: [resolve_target]
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","package"]'), needs.resolve_target.outputs.rerun_group)
permissions:
actions: read
@@ -249,8 +423,10 @@ jobs:
uses: ./.github/workflows/package-acceptance.yml
with:
workflow_ref: ${{ github.ref_name }}
source: ref
package_ref: ${{ needs.resolve_target.outputs.ref }}
source: artifact
artifact_run_id: ${{ github.run_id }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
suite_profile: custom
docker_lanes: bundled-channel-deps-compat plugins-offline
telegram_mode: mock-openai
@@ -263,6 +439,7 @@ jobs:
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -334,7 +511,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -381,7 +559,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.sha }}
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -402,7 +580,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -415,7 +594,7 @@ jobs:
- name: Download parity lane artifacts
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.sha }}
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
merge-multiple: true
@@ -436,7 +615,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.sha }}
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -458,7 +637,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -515,7 +695,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.sha }}
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -537,7 +717,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.ref }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -600,7 +781,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.sha }}
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -608,9 +789,11 @@ jobs:
summary:
name: Verify release checks
needs:
- prepare_release_package
- install_smoke_release_checks
- cross_os_release_checks
- live_and_e2e_release_checks
- live_repo_e2e_release_checks
- docker_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- qa_lab_parity_report_release_checks
@@ -627,9 +810,11 @@ jobs:
set -euo pipefail
failed=0
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
"cross_os_release_checks=${{ needs.cross_os_release_checks.result }}" \
"live_and_e2e_release_checks=${{ needs.live_and_e2e_release_checks.result }}" \
"live_repo_e2e_release_checks=${{ needs.live_repo_e2e_release_checks.result }}" \
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \

View File

@@ -38,6 +38,7 @@ jobs:
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}

View File

@@ -154,6 +154,8 @@ on:
required: false
CEREBRAS_API_KEY:
required: false
DEEPINFRA_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
@@ -260,6 +262,7 @@ jobs:
include_openwebui: ${{ steps.profile.outputs.include_openwebui }}
include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }}
package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }}
package_source_sha: ${{ steps.resolve.outputs.package_source_sha }}
package_sha256: ${{ steps.resolve.outputs.sha256 }}
package_version: ${{ steps.resolve.outputs.package_version }}
telegram_enabled: ${{ steps.profile.outputs.telegram_enabled }}
@@ -443,6 +446,7 @@ jobs:
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -490,7 +494,7 @@ jobs:
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
harness_ref: ${{ inputs.source == 'ref' && inputs.package_ref || inputs.workflow_ref }}
harness_ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }}
scenario: ${{ inputs.telegram_scenarios }}
secrets:

View File

@@ -42,7 +42,7 @@ jobs:
# followthrough gate that expects a fast post-approval read within a 30s
# agent.wait timeout.
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
@@ -57,9 +57,11 @@ jobs:
steps:
- name: Checkout PR
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
- name: Setup Node
uses: actions/setup-node@v6

View File

@@ -35,7 +35,7 @@ jobs:
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
@@ -44,6 +44,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
@@ -150,7 +151,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -164,6 +166,7 @@ jobs:
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
@@ -187,7 +190,7 @@ jobs:
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
@@ -209,7 +212,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -223,6 +227,7 @@ jobs:
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
@@ -266,7 +271,7 @@ jobs:
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}

View File

@@ -46,7 +46,7 @@ jobs:
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
@@ -54,6 +54,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
@@ -151,7 +152,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -185,7 +187,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment

View File

@@ -44,7 +44,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
@@ -81,12 +81,13 @@ jobs:
needs: authorize_actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_sha: ${{ steps.validate.outputs.selected_sha }}
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
@@ -98,27 +99,27 @@ jobs:
shell: bash
run: |
set -euo pipefail
selected_sha="$(git rev-parse HEAD)"
selected_revision="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_sha" == "$release_branch_sha" ]]; then
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
else
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
"repos/${GITHUB_REPOSITORY}/commits/${selected_revision}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_revision}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
trusted_reason="open-pr-head"
@@ -126,16 +127,16 @@ jobs:
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for this secret-bearing QA run." >&2
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA run." >&2
echo "Allowed refs must be on main, point to a release tag, match a release branch head, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "Validated ref: \`${INPUT_REF}\`"
echo "Resolved SHA: \`$selected_sha\`"
echo "Resolved SHA: \`$selected_revision\`"
echo "Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
@@ -157,7 +158,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -220,7 +222,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -303,7 +306,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -375,7 +379,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -467,7 +472,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules
.env
docker-compose.override.yml
docker-compose.extra.yml
docker-compose.sandbox.yml
dist
dist-runtime/
pnpm-lock.yaml

View File

@@ -6,20 +6,123 @@ Docs: https://docs.openclaw.ai
### Changes
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
### Fixes
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc.
- Active Memory: register the prompt-build hook with the configured recall timeout plus setup grace instead of the 150s maximum budget, so default memory recall cannot delay turn startup for multiple minutes. Thanks @vincentkoc.
- CLI/channels logs: reuse the rolling log-file resolver so `openclaw channels logs` falls back to the active dated log across date boundaries without reading unrelated custom log files. Fixes #42875; carries forward #42904 and #43043. Thanks @ethanclaw and @wdskuki.
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.
- Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash.
- Agents/bootstrap: pass pending BOOTSTRAP.md contents through the first-run user prompt while keeping them out of privileged system context, and show limited bootstrap guidance when workspace file access is unavailable. Fixes #73622. Thanks @mark1010.
- ACP/tasks: classify parent-owned ACP sessions as background work regardless of persistent runtime mode, and close terminal stale ACP sessions when no active binding remains, so delegated ACP output reports through the parent task notifier instead of acting like a normal foreground chat session. Refs #73609. Thanks @joerod26.
- Tasks: keep terminal mirrored TaskFlow timestamps pinned to task completion time and let maintenance repair stale mirrors, so ACP terminal delivery updates no longer leave inconsistent flow audits. Refs #73609. Thanks @joerod26.
- Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23.
- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler.
- Plugins/runtime-deps: cache unchanged bundled runtime mirror dist-file materialization decisions and close file-lock handles on owner-write failures, reducing repeated startup chunk scans and avoiding FileHandle-GC recovery stalls. Refs #73532. Thanks @oadiazp and @bstanbury.
- CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab.
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.
- Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval.
- Agents/transcripts: strip empty assistant text blocks while preserving valid text, images, and signatures, so Anthropic-style providers no longer reject sanitized transcript turns. Fixes #73640. Thanks @jowhee327.
- Providers/Bedrock: omit deprecated `temperature` for Claude Opus 4.7 Bedrock model ids, named and application inference profiles, including dotted `opus-4.7` refs, and classify the nested validation response for failover. Fixes #73663. Thanks @bstanbury.
- Gateway: raise the preauth/connect-challenge timeout to 15s so cold CLI starts on slower hosts have more time to process the WebSocket challenge before the Gateway closes the connection. Fixes #51469; refs #73592 and #62060. Thanks @GothicFox and @jackychen-png.
- CLI/status: fall back to a bounded local `status` RPC when loopback detail probes time out or report unknown capability, so reachable local gateways are no longer marked unreachable by slow read diagnostics. Fixes #73535; refs #48360, #62762, #51357, and #42019. Thanks @RacecarGuy, @justinschille, @DJBlackhawk, @tianyaqpzm, and @0xrsydn.
- CLI/gateway: reuse cached paired-device auth during `gateway probe` and report post-connect diagnostic failures as degraded reachability, so healthy local gateways are no longer marked unreachable after loopback auth or read timeouts. Fixes #48360. Thanks @RacecarGuy.
- Channels/Discord: give Discord Gateway WebSocket handshakes a 30s timeout so stalled TLS/network transitions emit an error and Carbon can continue its reconnect loop instead of leaving the bot silent until restart. Refs #50046. Thanks @codexGW.
- NVIDIA/NIM: persist the `NVIDIA_API_KEY` provider marker and mark bundled NVIDIA Chat Completions models as string-content compatible, so NIM models load from `models.json` and OpenAI-compatible subagent calls send plain text content. Fixes #73013 and #50107; refs #73014. Thanks @bautrey, @iot2edge, @ifearghal, and @futhgar.
- Channels/Discord: let text-only configs drop the `GuildVoiceStates` gateway intent and expose a bounded `/gateway/bot` metadata timeout with rate-limited fallback logs, reducing idle CPU and warning floods. Fixes #73709 and #73585. Thanks @sanchezm86 and @trac3r00.
- Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda.
- Outbound/security: strip known internal runtime scaffolding such as `<system-reminder>` and `<previous_response>` at the final channel delivery boundary and keep Discord output on targeted tag stripping, so degraded harness replies cannot leak those tags to users. Fixes #73595. Thanks @gabrielexito-stack and @martingarramon.
- Security/Telegram: load Telegram security adapters in read-only audit/doctor, audit malformed Telegram DM `allowFrom` entries even when groups are disabled, and keep allowlist DM audits from counting stale pairing-store senders, so public/shared-DM risk checks stay accurate. Refs #73698. Thanks @xace1825.
- Plugins: remove hidden manifest, provider-owner, bootstrap, and channel metadata caches so plugin installs, manifest edits, and bundled-root changes are visible on the next metadata read while keeping runtime/module loader caches for actual plugin code. Thanks @shakkernerd.
- CLI/plugins: use plugin metadata snapshots for install slot selection and add opt-in plugin lifecycle timing traces, so plugin install avoids runtime-loading the plugin registry for metadata-only decisions. Thanks @shakkernerd.
- fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987.
- fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987.
- Active Memory: allow `allowedChatTypes` to include explicit portal/webchat sessions and classify `agent:...:explicit:...` session keys before opaque session ids can shadow the chat type. Fixes #65775. (#66285) Thanks @Lidang-Jiang.
- Active Memory: allow the hidden recall sub-agent to use both `memory_recall` and the legacy `memory_search`/`memory_get` memory tool contract, so bundled `memory-lancedb` recall works without breaking the default `memory-core` path. Fixes #73502. (#73584) Thanks @Takhoffman.
- fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI]. (#72925) Thanks @pgondhi987.
- Active Memory docs: document the `cacheTtlMs` 1000-120000 ms range and 15000 ms default so setup snippets do not lead users past the schema limit. Fixes #65708. (#65737) Thanks @WuKongAI-CMU.
- fix(agents): canonicalize provider aliases in byProvider tool policy lookup [AI]. (#72917) Thanks @pgondhi987.
- fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987.
- Tools/web_fetch: decode response bodies from raw bytes using declared HTTP, XML, or HTML meta charsets before extraction, so Shift_JIS and other legacy-charset pages no longer return mojibake. Fixes #72916. Thanks @amknight.
- Active Memory: skip payload-less `memory_search` transcript tool results when building debug telemetry, so newer empty entries no longer hide the latest useful debug payload. (#68773) Thanks @SimbaKingjoe.
- Active Memory: keep recall setup time from consuming the configured model timeout while giving the hook runner an explicit bounded budget for the plugin, so slow embedded-run setup no longer causes immediate recall timeouts. Fixes #72606. (#72620) Thanks @hyspacex.
- Channels/Discord: bound message read/search REST calls, route those actions through Gateway execution, and fall back to `CommandTargetSessionKey` for inbound hook session keys so Discord reads do not hang and hooks still fire when `SessionKey` is empty. Fixes #73431. (#73521) Thanks @amknight.
- Plugins/media: auto-enable provider plugins referenced by `agents.defaults.imageGenerationModel`, `videoGenerationModel`, and `musicGenerationModel` primary/fallback refs, so configured Google and MiniMax media providers do not stay disabled behind a restrictive plugin allowlist. Thanks @vincentkoc.
- Memory-core/dreaming: retry managed dreaming cron registration after startup when the cron service is not reachable yet, so the scheduled Memory Dreaming Promotion sweep recovers without waiting for heartbeat traffic. Fixes #72841. Thanks @amknight.
- Acpx/runtime: validate the runtime session mode at the `AcpxRuntime.ensureSession` wrapper boundary so callers that pass anything other than `persistent` or `oneshot` get a clear `ACP_INVALID_RUNTIME_OPTION` error instead of silently round-tripping through the encoded handle as a default `persistent` mode and later throwing `SessionResumeRequiredError`. Investigation context: #73071. (#73548) Thanks @amknight.
- CLI/infer: keep web-search fallback on missing provider API keys, preserve structured validation errors from the selected provider, and let per-request image describe prompts override configured media-entry prompts. (#63263) Thanks @Spolen23.
- Chat commands: include configured model-catalog reasoning metadata when building `/think` argument menus so Ollama Cloud and other provider-owned reasoning models show supported levels instead of only `off`. Fixes #73515; supersedes #73568. Thanks @danielzinhu99 and @neeravmakwana.
- Channels/Telegram: suppress generic tool-progress chatter when preview streaming is off, so non-streaming Telegram turns only deliver final replies while approvals, media, and errors still route normally. Refs #72363 and #72482. Thanks @neeravmakwana and @SweetSophia.
- CLI/model probes: add repeatable image `--file` inputs to `infer model run` for local and gateway multimodal model smokes, so vision models such as Ollama Qwen VL and Gemini can be tested through the raw model-probe surface. Fixes #63700. Thanks @cedricjanssens.
- CLI/model probes: request trusted operator scope for `infer model run --gateway --model <provider/model>` so Gateway raw model smokes can use one-off provider/model overrides instead of being rejected before provider auth resolution. Fixes #73759. Thanks @chrislro.
- CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Refs #63700. Thanks @cedricjanssens.
- Model selection: include the rejected provider/model ref and allowlist recovery hint when a stored session override is cleared, so local model selections such as Gemma GGUF variants do not fall back to the default with a generic message. Refs #71069. Thanks @CyberRaccoonTeam.
- OpenAI-compatible providers: drop malformed event-only or blank-data SSE frames before the OpenAI SDK stream parser sees them, so proxies that split `event:` from `data:` no longer crash streaming runs with `Unexpected end of JSON input`. Fixes #52802. Thanks @LyHug.
- Local model prompt caching: keep stable Project Context above volatile channel/session prompt guidance and stop embedding current channel names in the message tool description, so Ollama, MLX, llama.cpp, and other prefix-cache backends avoid avoidable full prompt reprocessing across channel turns. Fixes #40256; supersedes #40296. Thanks @rhclaw and @sriram369.
- Gateway/OpenAI-compatible API: guard provider policy lookup against runtime providers with non-array `models` values, so `/v1/chat/completions` no longer fails with `provider?.models?.some is not a function`. Fixes #66744; carries forward #66761. Thanks @MightyMoud, @MukundaKatta.
- WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark.
- Channels/Telegram: persist native command metadata on target sessions so topic, helper, and ACP-bound slash commands keep their session metadata attached to the routed conversation. (#57548) Thanks @GaosCode.
- Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus.
- Pairing/doctor: bootstrap `commands.ownerAllowFrom` from the first approved DM pairing when no command owner exists, and have doctor explain missing owners so privileged slash commands are not accidentally unusable after onboarding. Thanks @pashpashpash.
- Telegram/exec: infer native exec approvers from `commands.ownerAllowFrom` and auto-enable the Telegram approval client when an owner is resolvable, so owner-only commands such as `/diagnostics` can be approved in Telegram without duplicate per-channel approver config. Thanks @pashpashpash.
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
- Agents/context engines: preserve the child agent's configured `agentDir` when subagent cleanup re-resolves a context engine, so `onSubagentEnded` hooks keep operating on the correct per-agent state. (#67243) Thanks @jarimustonen.
- Channels/WhatsApp: restrict pairing verification replies to real inbound user content, preventing unsolicited prompts from receipts, typing indicators, presence updates, and other non-message Baileys upserts. Fixes #73797. (#73823) Thanks @hclsys.
- Configure/Ollama: show the configured Ollama model allowlist after Cloud only or Cloud + Local setup and skip slow per-model cloud metadata fetches. (#73995) Thanks @obviyus.
## 2026.4.27
### Changes
- Sandbox/Docker: add opt-in `sandbox.docker.gpus` passthrough for Docker sandbox containers so local GPU workloads can run inside sandboxed agents when the host Docker runtime supports `--gpus`. Fixes #57976; carries forward #58124. Thanks @cyan-ember.
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
- Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant.
- Dependencies: refresh provider and tooling dependencies, including AWS SDK, PI runtime packages, AJV, Feishu SDK, Anthropic SDK, tokenjuice, and native TypeScript/oxlint tooling. Thanks @dependabot.
- Matrix/QA: add live Matrix approval scenarios for exec metadata, chunked fallback, plugin approvals, deny reactions, thread targeting, and `target: "both"` delivery, with redacted artifacts preserving safe approval summaries. Thanks @gumadeiras.
- Diagnostics/Codex: add owner-only core `/diagnostics` with a sensitive-data preamble, docs link, and explicit Gateway export approval guidance; Codex harness sessions also ask before uploading Codex feedback for the attached thread and print the matching `codex resume <thread-id>` inspection command after confirmed upload. Thanks @pashpashpash.
- Trajectory export: route `/export-trajectory` through per-run exec approval, send group-chat approval prompts and export results only to the owner privately, and add `openclaw sessions export-trajectory` for the approved command path. Thanks @pashpashpash.
- Codex: add Computer Use setup for Codex-mode agents, including `/codex computer-use status/install`, marketplace discovery, optional auto-install, and fail-closed MCP server checks before Codex-mode turns start. Fixes #72094. (#71842) Thanks @pash-openai.
- Apps: consume Peekaboo 3.0.0-beta4 and ElevenLabsKit 0.1.1, align Swabble on Commander 0.2.2, and refresh macOS/iOS SwiftPM resolutions against the released dependency graph. Thanks @Blaizzy.
- Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc.
- Docs/Codex: document how Codex Computer Use, direct `cua-driver mcp`, and OpenClaw.app's PeekabooBridge fit together so desktop-control setup choices are clearer. Thanks @pash-openai and @trycua.
- Matrix/streaming: stream tool-progress updates into live Matrix preview edits by default when preview streaming is active, with `streaming.preview.toolProgress: false` to keep answer previews while hiding interim tool lines. Thanks @gumadeiras.
- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with stale Spark and Qwen Coding Plan suppressions now declared in plugin manifests instead of runtime fallback hooks. Thanks @shakkernerd.
- Plugin SDK/models: add a shared manifest-backed provider catalog builder and move Qianfan, Xiaomi, NVIDIA, Cerebras, Mistral, Moonshot, DeepSeek, Tencent TokenHub, and StepFun provider catalogs onto their plugin manifest `modelCatalog` rows. Thanks @shakkernerd.
- Plugin SDK/models: move BytePlus and Volcano Engine standard and plan-provider catalogs into plugin manifest `modelCatalog` rows and remove the now-unused Volcengine-family shared catalog SDK subpath. Thanks @shakkernerd.
- CLI/models: move Fireworks and Together AI fixed provider catalogs into plugin manifest `modelCatalog` rows so provider-filtered listing can use manifest-backed static rows. Thanks @shakkernerd.
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
- Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd.
- Plugins/startup: add an opt-in future-mode gate for disabling deprecated implicit startup sidecar loading while preserving explicit startup and narrower activation triggers. Thanks @shakkernerd.
- Plugins/startup: add plugin compatibility warnings for deprecated implicit startup loading so authors can migrate to explicit `activation.onStartup` metadata. Thanks @shakkernerd.
- Plugins/runtime: load bundled agent tool-result middleware from manifest contracts on demand so tokenjuice stays startup-lazy without losing Pi/Codex tool-output compaction. Thanks @shakkernerd.
- Plugins/startup: add explicit `activation.onStartup` metadata so plugins can declare Gateway startup import behavior while the deprecated implicit sidecar fallback remains for legacy plugins. Thanks @shakkernerd.
- Gateway/startup: reuse lookup-table plugin manifests when loading startup plugins so Gateway boot avoids rebuilding plugin discovery and manifest metadata. Thanks @shakkernerd.
- CLI/models: declare fixed Qianfan, Xiaomi, NVIDIA, Cerebras, Mistral, Chutes, Kilo, OpenAI, and OpenCode Go model catalogs in refreshable plugin manifests, keep broad `models list --all` on raw registry and supplement rows without runtime normalization, and avoid duplicate supplement resolution. Thanks @shakkernerd.
- Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
- Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd.
- Plugin SDK/testing: move core-only channel contract fixtures under the channel contract test tree and retire the old `test/helpers/channels` bridge directory so plugin tests stay on focused SDK surfaces. Thanks @vincentkoc.
- Plugin SDK/testing: expose native agent-runtime contract fixtures through `plugin-sdk/agent-runtime-test-contracts`, move sandbox config fixtures into the focused generic fixture subpath, and block extension tests from importing repo-only `test/helpers` bridges. Thanks @vincentkoc.
- Plugin SDK/testing: expose generic module reload, bundled-path, Node builtin mock, channel pairing/envelope, HTTP server, temp-home, replay-policy, and live STT helpers through focused SDK test subpaths so extension tests no longer depend on repo-only helper bridges. Thanks @vincentkoc.
- Plugin SDK: move maintained bundled channels off the deprecated `channel-config-schema-legacy` subpath, add an explicit bundled-channel schema SDK surface, and track both remaining legacy test/config compatibility barrels with dated removal windows. Thanks @vincentkoc.
- Plugin SDK/testing: expose media provider capability assertions and provider HTTP mocks through focused SDK test subpaths, and retire the repo-only media-generation test helper bridge. Thanks @vincentkoc.
- Plugin SDK/testing: promote bundled plugin/provider/channel contract helpers to focused SDK test subpaths and retire the repo-only `test/helpers/plugins` TypeScript bridge. Thanks @vincentkoc.
@@ -35,14 +138,137 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: fix Peak Error Hours showing incorrect hourly rates when the browser's timezone observes DST, by storing hourly message counts with UTC date keys and using DST-aware `Date.getHours()` for local conversion. Also extract `accumulateMessageCounts` helper to reduce duplicated daily/hourly aggregation logic. (#49396) Thanks @konanok.
- CLI/channel-setup: auto-skip the redundant "Install \<plugin\>?" confirmation when only one install source (npm or local) exists, show `download from <npm-spec>` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp.
- BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris.
- Agents/approvals: fail restart-interrupted sessions whose transcript tail is still `approval-pending` instead of replaying stale exec approval IDs into the new Gateway process after restart. Fixes #65486. Thanks @mjmai20682068-create.
- CLI/Gateway: use method-specific least-privilege scopes for classified CLI Gateway calls while preserving legacy broad scopes for unclassified plugin methods, so read-only commands no longer create admin/write/pairing scope-upgrade prompts. Fixes #68634. Thanks @nightmusher.
- Gateway/sessions: align `chat.history` and `sessions.list` thinking defaults with owning-agent and catalog-aware resolution so Control UI session defaults match backend runtime state. (#63418) Thanks @jpreagan.
- Devices/pairing: recover array-shaped device and node pairing state files before persisting approvals, so UUID-keyed pending and paired entries no longer disappear after a malformed JSON store write. Fixes #63035. Thanks @sar618.
- Gateway/auth: clear reused stale device tokens and stop reconnecting on device-token mismatch in the Control UI and Node gateway clients, avoiding rate-limit loops after scope-upgrade or token-rotation handoffs. Fixes #71609. Thanks @ricksayhi.
- Gateway/approvals: treat duplicate same-decision approval resolves as idempotent during the resolved-entry grace window, including consumed `allow-once` approvals, while returning an explicit already-resolved error for conflicting repeats. Fixes #59162; refs #58479 and #65486. Thanks @wikithoughts, @sajazuniga7-coder, and @mjmai20682068-create.
- Channels/Telegram: honor `approvals.exec/plugin.targets[].accountId` when routing native approvals across multi-bot Telegram accounts while preserving unscoped Telegram targets for any account. Fixes #69916. Thanks @joerod26.
- Agents/exec: omit the internal session-resume fallback preface from successful async exec completion messages sent directly back to chat. Fixes #67181. Thanks @raistlin88.
- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc.
- Agents/media: prefer OpenAI image and video providers when the default model uses the OpenAI Codex auth alias, so auto media generation no longer falls through to Fal before GPT Image or Sora. Thanks @vincentkoc.
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.
- Auto-reply: bound the post-run pending tool-result delivery drain with a progress-aware idle timeout, so a never-settling tool-result task no longer leaves the session active forever while slow healthy deliveries can keep draining. Fixes #53889; supersedes #64733 and #73434. Thanks @zijunl and @wujiaming88.
- Gateway/startup: start chat channels without waiting for primary model prewarm, keeping model warmup bounded in the background so Slack and other channels come online promptly when provider discovery is slow. Supersedes #73420. Thanks @dorukardahan.
- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh.
- Auto-reply/commands: stop bare `/reset` and `/new` after reset hooks acknowledge the command, so non-ACP channels no longer fall through into empty provider calls while `/reset <message>` and `/new <message>` still seed the next model turn. Fixes #73367 and #73412. Thanks @hoyanhan, @wenxu007, and @amdhelper.
- Providers/DeepSeek: backfill DeepSeek V4 `reasoning_content` on plain assistant replay messages as well as tool-call turns, so thinking sessions with prior tool use no longer fail follow-up requests with missing reasoning content. Fixes #73417; refs #71372. Thanks @34262315716 and @Bartok9.
- Agents/gateway tool: strip full config payloads from `config.patch` and `config.apply` tool responses while preserving direct RPC responses, so config-heavy sessions no longer replay large redacted configs into transcript history. Fixes #47610; supersedes #73439. Thanks @HanenVit and @juan-flores077.
- Auto-reply: preserve voice-note media from silent turns while continuing to suppress text and non-voice media, so `NO_REPLY` TTS replies still deliver the requested audio bubble. (#73406) Thanks @zqchris.
- Channels/Mattermost: stop enqueueing regular inbound posts as system events, so Mattermost user messages reach the model only as user-role inbound-envelope content instead of also appearing as `System: Mattermost message...` directives. Fixes #71795. Thanks @juan-flores077.
- Agents/media: qualify bare `agents.defaults.imageModel` and `pdfModel` refs from unique configured image-capable providers, so Ollama vision models such as `moondream` and `qwen2.5vl:7b` do not fall through to the default provider. Fixes #38816; supersedes #73396. Thanks @alainasclaw and @vincentkoc.
- Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski.
- Skills: require explicit `skills.entries.coding-agent.enabled` before exposing the bundled coding-agent skill, so installs with Codex on PATH but no OpenAI auth do not silently offer Codex delegation. Fixes #73358. Thanks @LaFleurAdvertising and @Sanjays2402.
- Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss.
- Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola.
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui.
- Plugins/runtime deps: refresh bundled runtime mirrors without deleting active import trees, so config-triggered restarts do not see transient missing plugin files during registration. Thanks @shakkernerd.
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
- CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc.
- CLI/plugins: scope `plugins inspect <id>` runtime loading to the matched plugin so single-plugin inspection does not load every plugin before checking the target. Thanks @shakkernerd.
- CLI/plugins: remove managed copied-path plugin directories during uninstall and plan uninstall from metadata instead of runtime-loading plugins, so plugin lifecycle commands avoid unnecessary bundled runtime-deps work. Thanks @shakkernerd.
- Cron tool: infer the creating session's agentId for `cron.add` jobs when `agentId` is omitted or passed as undefined, keeping scheduled agentTurn jobs routed to the session agent; #40571 identified the guard bug and supplied the focused regression coverage. Thanks @ChanningYul.
- Cron/Telegram: add `--thread-id` to `openclaw cron add` and `openclaw cron edit`, preserving Telegram forum topic delivery targets across scheduled announcements. Carries forward #51581, #60373, and #60890. Thanks @ChunHao-dev.
- Cron/Telegram: preserve session-derived Telegram topic thread IDs when isolated cron delivery explicitly targets the parent chat, keeping bare chat targets in the active forum topic without leaking stale topics to other chats. Carries forward #64708. Thanks @addelh.
- Memory/compaction: keep pre-compaction memory-flush prompts runtime-only so session transcripts and `chat.history` no longer expose them as normal user turns. Fixes #54408 and #58956; refs #43567. Thanks @markgong and @guoyuhang9.
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
- Sandbox/Docker: stop satisfying a missing default sandbox image by tagging plain Debian as `openclaw-sandbox:bookworm-slim`, preserving the Python tooling required by sandbox write/edit helpers and directing users to build the default image. Fixes #51185; refs #45108, #51099, #51609, and #57713. Thanks @dpalis, @Tin55FoilDev, @jbcohen2-coder, @macminihal-cyber, and @PraxoOnline.
- Control UI/WebChat: confirm toolbar New Session button resets before dispatching `/new` while leaving typed `/new` and `/reset` commands immediate. Fixes #45800; refs #27065, #56611, #54499, and #27110. Thanks @aethnova, @kosta228-huli, @adambezemek, and @xss925175263 (xianshishan).
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Gateway/startup: warn when legacy `CLAWDBOT_*` or `MOLTBOT_*` environment variables are still present, pointing users to `OPENCLAW_*` names instead of failing silently. Fixes #53482; carries forward #53667. Thanks @lndyzwdxhs.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.
- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9.
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby.
- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar.
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
- CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer.
- CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Addresses #63700. Thanks @cedricjanssens.
- Providers/Ollama: reject long non-linguistic Kimi/GLM symbol runs as provider failures instead of storing them as successful visible assistant replies, so fallback or error handling can recover from garbled cloud output. Fixes #64262; refs #67019. Thanks @Kloz813 and @xiaomenger123.
- CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge.
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.
- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills.
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata and preserve explicit native `params.think`/`params.thinking`, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
- Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch.
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.
- CLI/onboarding: infer image input for common custom-provider vision model IDs, ask only for unknown models, and keep `--custom-image-input`/`--custom-text-input` overrides so vision-capable proxies do not get saved as text-only configs. Fixes #51869. Thanks @Antsoldier1974.
- Models/OpenAI Codex: stop listing or resolving unsupported `openai-codex/gpt-5.4-mini` rows through Codex OAuth, keep stale discovery rows suppressed with a clear API-key-route hint, and leave direct `openai/gpt-5.4-mini` available. Fixes #73242. Thanks @0xCyda.
- Plugin SDK: restore the root `stringEnum` and `optionalStringEnum` exports on both the published SDK entry and runtime root-alias bridge, so older external plugins can keep building and loading while migrating to focused SDK subpaths. Fixes #68279. Thanks @marzliak.
- Plugin SDK: restore the root-alias bridge for `registerContextEngine` and expose missing legacy compat helpers `normalizeAccountId` and `resolvePreferredOpenClawTmpDir` so older external plugins such as `openclaw-weixin` can keep loading while migrating to focused SDK subpaths. Fixes #53497. Thanks @alanxchen85.
- Auth profiles: make `openclaw doctor --fix` migrate legacy flat `auth-profiles.json` files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` to canonical provider default API-key profiles with a backup, so custom Ollama/OpenAI-compatible providers recover cleanly after upgrading. Fixes #59629; supersedes #59642. Thanks @Xsanders555 and @Linux2010.
- Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy.
- Feishu/inbound files: recover CJK filenames from plain `Content-Disposition: filename=` download headers when Feishu exposes UTF-8 bytes through Latin-1 header decoding, while leaving valid Latin-1 and JSON-derived names unchanged. (#48578, #50435, #59431) Thanks @alex-xuweilong, @lishuaigit, and @DoChaoing.
- Channels/Telegram: normalize accidental full `/bot<TOKEN>` Telegram `apiRoot` values at runtime and teach `openclaw doctor --fix` to remove the suffix, so startup control calls no longer 404 when direct Bot API curl commands work. Fixes #55387. Thanks @brendanmatthewjones-cmyk, @techfindubai-ux, and @Sivlerback-Chris.
- Zalo Personal: persist refreshed `zca-js` session cookies after QR login, session restore, and successful API calls so gateway restarts restore the freshest local session. (#73277) Thanks @darkamenosa.
- Logging/security: redact sensitive tokens (sk-\* keys, Bearer/Authorization values, etc.) at the subsystem console sink so `createSubsystemLogger().info/warn/error` output that bypasses the patched console-capture handler still applies the same redaction the file transport already does. Fixes #73284; refs #67953 and #64046. Thanks @edwin-rivera-dev.
- Plugins/runtime deps: reuse enclosing versioned cache roots when bundled plugins resolve from nested staged paths, so plugin-runtime-deps no longer mints `openclaw-unknown-*` directories or loops on `ENOTEMPTY`. Fixes #72956. (#73205) Thanks @SymbolStar.
- Agents/failover: classify CJK provider transport, quota, billing, auth, and overload error text so Chinese-language provider failures trigger fallback and user-facing transport copy instead of surfacing as unclassified raw errors. (#56242) Thanks @tomcatzh.
- Agents/failover: seed non-claude-cli fallback prompts with Claude Code session context when a claude-cli attempt fails, so fallback models do not restart cold after billing or quota failover. (#72069) Thanks @stainlu.
- Agents/CLI runner: transfer bundle-MCP tempDir cleanup from the per-turn runner finally to the Claude live-session lifecycle, so persistent Claude CLI sessions keep their `--mcp-config` directory until the live subprocess closes. Fixes #73244. Thanks @edwin-rivera-dev.
- Gateway/nodes: allow Windows companion nodes to use safe declared commands such as canvas, camera list, location, device info, and screen snapshot by default while keeping dangerous media commands opt-in. (#71884) Thanks @shanselman.
- Agents/cron: clarify agent-tool and CLI cron timezone guidance so supplied `tz` values use local wall-clock cron fields and omitted cron `tz` falls back to the Gateway host local timezone. Fixes #53669; carries forward #46177. (#73372) Thanks @chen-zhang-cs-code and @maranello-o.
- Providers/Qwen: allow explicitly configured `qwen/qwen3.6-plus` to resolve on Qwen Coding Plan endpoints while keeping the built-in catalog from advertising it there. Fixes #63654; carries forward #63987. Thanks @jepson-liu.
- Channels/Telegram: keep Bot API network fallbacks sticky after failed attempts and retry timed-out startup control calls once on the fallback route, so `deleteWebhook` IPv6 stalls no longer trigger slow multi-account retry storms. Fixes #73255. Thanks @ttomiczek and @sktbrd.
- Gateway/agents: accept heartbeat, cron, and webhook as internal channel hints for agent runs so `sessions_spawn` works from non-delivery parent sessions while unknown channel hints still fail closed. Fixes #73237. Thanks @KeWang0622.
- Gateway/models: merge explicit `models.providers.*.models` rows into the Gateway model catalog with normalized provider/model dedupe, and use normalized image-capability lookup so custom vision models keep native image attachments even when Pi discovery omits them or model ID casing differs. Fixes #64213 and #65165. Thanks @billonese and @202233a.
- Gateway/reload: publish canonical post-write source config to in-process reloaders so simple config saves no longer create phantom plugin diffs or trigger unnecessary Gateway restarts. (#73267) Thanks @szsip239.
- Gateway/Docker: keep config-triggered restarts in-process inside containers instead of spawning a detached child and exiting PID 1 cleanly, so Docker Swarm and other on-failure supervisors do not leave the service stuck at 0/1 replicas. Fixes #73178. Thanks @du-nguyen-IT007.
- CLI/tasks: ship the task-registry control runtime in npm packages so `openclaw tasks cancel` can load ACP/subagent cancellation helpers from published builds. Fixes #68997. Thanks @1OAKDesign.
- Channels/Telegram: preserve unsent generated media after partial reply streaming has already delivered the text, so `image_generate` outputs still reach Telegram as photos instead of being dropped from the final payload. Fixes #73253. Thanks @mlaihk.
- Memory-core/dreaming: cap detached Dream Diary narrative subagents across cron sweeps so multi-workspace dreaming no longer fans out unbounded subagent sessions, lock contention, and cascading narrative timeouts. Fixes #73198. (#73287) Thanks @KeWang0622.
- CLI/agents: close local one-shot Claude live stdio sessions and bundled MCP loopback resources after embedded `openclaw agent --local` runs, while keeping gateway-owned MCP loopback cleanup internal to the Gateway. Thanks @frankekn.
- Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp.
- Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26.
- Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96.
- macOS/update: stop managed Gateway services before package replacement and keep LaunchAgent service secrets out of world-readable plist metadata by loading them from owner-only env files. Fixes #72996. Thanks @Mathewb7.
- Google Meet: keep observe-only Chrome joins and setup checks from requiring BlackHole or audio bridge commands, avoid granting or selecting the microphone in observe-only mode, and make `test_speech` report fresh realtime output-byte verification instead of only confirming a queued utterance. Refs #72478. Thanks @DougButdorf.
- Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868.
- Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw.
- CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg.
- Media/security: tighten media-understanding MIME sanitization so parameterized MIME values stay end-anchored and malformed whitespace or suffix payloads are rejected before file-context handling. Fixes #9795; carries forward #68225 with related review/test context from #61016/#68456. Thanks @ymaxgit, @bluesky6868, and @shamsulalam1114.
- Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo.
- Compaction/diagnostics: keep unknown compaction failure classifications stable while logging sanitized detail for unclassified provider errors such as missing Ollama provider adapters. Thanks @gzsiang.
- Models/fallbacks: record first-class `model.fallback_step` trajectory events with from/to models, failure detail, chain position, and final outcome so support exports preserve the primary model failure even when a later fallback also fails. Fixes #71744. Thanks @nikolaykazakovvs-ux.
- Gateway/agents: block agent `exec` from launching interactive `openclaw channels login` flows and abort active agent runs after invalid-config recovery restores last-known-good config, preventing known channel-login and reload paths from wedging replies. Refs #72338. Thanks @midhunmonachan.
- Gateway/diagnostics: emit payload-free liveness warnings with event-loop delay, event-loop utilization, CPU-core ratio, active-session counts, and OTEL warning metrics/spans so live-but-stalled Gateways capture CPU-spin context in stability bundles and telemetry. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
- Gateway/startup: keep value-option foreground starts on the gateway fast path and skip proxy bootstrap unless proxy env is configured, reducing normal gateway startup RSS and avoiding full CLI graph loading. Thanks @vincentkoc.
- Heartbeat/models: show heartbeat model bleed guidance on context-overflow resets when the last runtime model matches configured `heartbeat.model`, so smaller local heartbeat models point users to `isolatedSession` or `lightContext` instead of only compaction-buffer tuning. Fixes #67314. Thanks @Knightmare6890.
- Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99.
- Channels/Telegram: keep webhook-mode local listeners alive and retry Telegram `setWebhook` registration after recoverable startup network failures, so transient Bot API timeouts no longer leave reverse proxies pointing at a closed listener. Fixes #71834. Thanks @jinon86.
- Agents/ACPX: bundle the Codex ACP adapter and launch it from the isolated `CODEX_HOME` wrapper before falling back to npm, so Codex ACP startup no longer depends on live `npx` resolution or the stale `@zed-industries/codex-acp@^0.11.1` range. Fixes #72037; refs #73202. Thanks @jasonftl, @sazora, and @joerod26.
- Agents/ACPX: register the embedded ACP backend at Gateway startup through a lightweight ACP backend SDK path and without importing the heavy ACPX runtime until an ACP session or explicit startup probe needs it, reducing baseline Gateway RSS. Thanks @vincentkoc.
- CLI/update: keep restart health polling when the restarted Gateway is reachable but has not reported its version yet, so macOS service restarts do not fail early with `actual unavailable`. Thanks @ProspectOre.
- Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang.
- Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang.
- Memory/QMD: back off repeated chat-turn QMD open failures while still letting memory status and CLI probes recheck immediately, so a broken sidecar dependency cannot trigger active-memory or cron retry storms. Fixes #73188 and #73176. Thanks @leonlushgit and @w3i-William.
- Talk Mode: resolve `messages.tts.providers.<id>.apiKey` through the active runtime snapshot for `talk.config`, so Talk overlays can discover SecretRef-backed speech providers without falling back to local speech. Fixes #73109. (#73111) Thanks @omarshahine.
- Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers.<id>.api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang.
- CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana.
- CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva.
- Plugins/runtime deps: prepare staged bundled plugin dependencies before loading packaged public surfaces, so OpenClaw's Telegram runtime/test facade loads resolve `grammy` from the managed runtime-deps stage without copying dependencies into the global package root. Refs #73140. Thanks @oalansilva.
- Agents/exec: emit `(no output)` for silent exec update and node-host result blocks so Anthropic-compatible providers no longer reject empty tool-result text after quiet commands. Fixes #73117. Thanks @pfrederiksen and @Sanjays2402.
- Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead.
- Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh.
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.
- CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius.
- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler.
- Discord/group chats: keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Plugins/package: force nested bundled-plugin runtime dependency installs out of inherited npm dry-run mode during prepack and package smoke checks, so packed installs materialize required plugin modules instead of reporting missing bundled files. Refs #73128. Thanks @Adam-Researchh.
- Discord: skip reaction events before REST channel fetch when notifications are off, guild reactions are disabled, or allowlist mode cannot match without channel overrides, reducing reconnect bursts that caused slow listener warnings. Fixes #73133. Thanks @isaacsummers.
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.
@@ -91,6 +317,7 @@ Docs: https://docs.openclaw.ai
- Channels/Telegram: stop native approval startup auth failures from retrying every second, while still waiting through retryable Gateway auth handoffs, so Telegram approval setup problems no longer create a reconnect/log loop during channel startup. Refs #72846 and #72867. Thanks @kiranvk-2011 and @porly1985.
- Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000.
- Gateway/auth: allow local direct callers in trusted-proxy mode to use the configured gateway password as an internal fallback while keeping token fallback rejected. Fixes #17761. Thanks @dashed, @vincentkoc, and @jetd1.
- Gateway/auth: add explicit `trustedProxy.allowLoopback` support for same-host loopback reverse proxies while keeping loopback trusted-proxy auth fail-closed by default and preserving required-header and allowlist checks. Fixes #59167; carries forward #63379. Thanks @Matir, @jeremyakers, and @mrosmarin.
- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov.
- Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz.
- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03.
@@ -144,13 +371,7 @@ Docs: https://docs.openclaw.ai
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
### Fixes
- CLI/models: keep Chutes and Kilo static catalog rows visible through refreshable manifest catalog metadata while provider-filtered refreshable providers remain registry-backed and merge manifest rows as supplements. Thanks @shakkernerd.
- CLI/models: skip duplicate catalog supplement resolution during broad `models list --all` output so already-listed registry rows do not pay a second registry lookup pass. Thanks @shakkernerd.
- CLI/models: move OpenAI and OpenCode Go forward-compat list rows into refreshable manifest catalogs and stop broad `models list --all` from loading runtime catalog supplement hooks. Thanks @shakkernerd.
- CLI/models: keep broad unfiltered `models list --all` on raw registry rows instead of loading every provider runtime normalization hook, while preserving full normalization for provider-filtered and configured model paths. Thanks @shakkernerd.
- Cron: normalize isolated job tool allowlists before granting the narrow self-removal cron tool path, keeping scheduled jobs aligned with shared tool policy normalization. (#73028) Thanks @jalehman.
## 2026.4.26
@@ -158,6 +379,7 @@ Docs: https://docs.openclaw.ai
- Control UI/Talk: add a generic browser realtime transport contract, Google Live browser Talk sessions with constrained ephemeral tokens, and a Gateway relay for backend-only realtime voice plugins. Thanks @VACInc.
- CLI/models: route provider-filtered model listing through an explicit source plan so user config, installed manifest rows, Provider Index previews, and scoped runtime fallbacks keep a stable authority order without adding another catalog cache. Thanks @shakkernerd.
- Plugins/cron: add a typed `cron_changed` hook for observing gateway-owned cron lifecycle updates without depending on internal cron events. Thanks @amknight.
- Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata.
- Memory/OpenAI-compatible: add optional `memorySearch.inputType`, `queryInputType`, and `documentInputType` config for asymmetric embedding endpoints, including direct query embeddings and provider batch indexing. Carries forward #63313 and #60727. Thanks @HOYALIM and @prospect1314521.
- Ollama/memory: add model-specific retrieval query prefixes for `nomic-embed-text`, `qwen3-embedding`, and `mxbai-embed-large` memory-search queries while leaving document batches unchanged. Carries forward #45013. Thanks @laolin5564.
@@ -314,6 +536,8 @@ Docs: https://docs.openclaw.ai
- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.
- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
- WhatsApp: clear cached Web auth and active listener state after terminal 440/401 conflict/logout closes so linked/OK status no longer masks a dead inbound listener after relink or restart. Fixes #45474; refs #49305, #63855, #66920, and #70856. Thanks @juvenalmakoszay and @dsantoreis.
- Gateway/restart: keep local restart-health probes on configured local daemon auth without falling back to remote gateway credentials. (#57374, #59439) Thanks @zssggle-rgb and @roytong9.
- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain.
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
- Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng.
@@ -324,6 +548,7 @@ Docs: https://docs.openclaw.ai
- TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.
- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when `systemctl --user` reports no-medium bus failures, without letting stale `SUDO_USER` override `sudo -u` installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.
- CLI/nodes: make unfiltered `openclaw nodes list` prefer the effective paired-node view used by `nodes status` while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412.
- Memory Wiki/CLI: route active bridge-mode status, doctor, and bridge imports through Gateway RPC so CLI checks use the runtime memory plugin context while disabled bridge imports stay local/offline. Carries forward #67208 and #71479; related #70185. Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
- Feishu/Lark: stop treating broadcast-only `@all`/`@_all` messages as bot mentions while preserving direct bot mentions, including messages that also include `@all`. Fixes #37706. Thanks @JosepLee.
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
@@ -382,6 +607,7 @@ Docs: https://docs.openclaw.ai
- Gateway health: preserve live runtime-backed channel/account state in `gateway.health` snapshots and cached refreshes while keeping raw probe payloads on sensitive/admin paths only. (#39921, #42586, #46527, #52770, #42543) Thanks @FAL1989, @rstar327, @0xble, and @ajayr.
- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.
- Telegram/agents: hide raw failed write/edit warning messages in Telegram when the assistant already explicitly acknowledges the failed action, while keeping warnings when the reply claims success or omits the failure; #39406 remains the broader configurable delivery-policy follow-up. Fixes #51065; covers #39631. Thanks @Bartok9 and @Bortlesboat.
- TUI: clear stale streaming status when an orphaned final event or watchdog reset leaves no tracked active run, flushing deferred local history refreshes without surfacing inactive-run failures. Fixes #64825; carries forward #52745. Thanks @lyksdu.
- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim.
- Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.
- ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.
@@ -833,6 +1059,7 @@ Docs: https://docs.openclaw.ai
- Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.
- DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.
- Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.
- Providers/OpenRouter: add native video generation through `video_generate`, so OpenRouter video models work with `OPENROUTER_API_KEY`. (#72700) Thanks @notamicrodose.
- Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.
- Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.

View File

@@ -130,7 +130,8 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime
@@ -258,10 +259,12 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs
# Pre-create the default state dir so first-run Docker named volumes mounted
# here inherit node ownership instead of starting as root-owned state.
# Pre-create the default state and runtime-deps dirs so first-run Docker named
# volumes mounted here inherit node ownership instead of root-owned state.
RUN install -d -m 0700 -o node -g node /home/node/.openclaw && \
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700'
install -d -m 0700 -o node -g node /var/lib/openclaw/plugin-runtime-deps && \
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \
stat -c '%U:%G %a' /var/lib/openclaw/plugin-runtime-deps | grep -qx 'node:node 700'
ENV NODE_ENV=production

View File

@@ -2,6 +2,241 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.4.26</title>
<pubDate>Tue, 28 Apr 2026 02:40:27 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042690</sparkle:version>
<sparkle:shortVersionString>2026.4.26</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.26</h2>
<h3>Changes</h3>
<ul>
<li>Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C <code>stream_messages</code> streaming with a <code>StreamingController</code> lifecycle manager, unified <code>sendMedia</code> with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via <code>createEngineAdapters()</code>. (#70624) Thanks @cxyhhhhh.</li>
<li>Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (<code>openclaw-plugin-yuanbao</code>) in the official channel catalog, contract suites, and community plugin docs, with a new <code>docs/channels/yuanbao.md</code> quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.</li>
<li>Control UI/Talk: add a generic browser realtime transport contract, Google Live browser Talk sessions with constrained ephemeral tokens, and a Gateway relay for backend-only realtime voice plugins. Thanks @VACInc.</li>
<li>CLI/models: route provider-filtered model listing through an explicit source plan so user config, installed manifest rows, Provider Index previews, and scoped runtime fallbacks keep a stable authority order without adding another catalog cache. Thanks @shakkernerd.</li>
<li>Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata.</li>
<li>Memory/OpenAI-compatible: add optional <code>memorySearch.inputType</code>, <code>queryInputType</code>, and <code>documentInputType</code> config for asymmetric embedding endpoints, including direct query embeddings and provider batch indexing. Carries forward #63313 and #60727. Thanks @HOYALIM and @prospect1314521.</li>
<li>Ollama/memory: add model-specific retrieval query prefixes for <code>nomic-embed-text</code>, <code>qwen3-embedding</code>, and <code>mxbai-embed-large</code> memory-search queries while leaving document batches unchanged. Carries forward #45013. Thanks @laolin5564.</li>
<li>Plugins/providers: move pre-runtime model-id normalization, endpoint host metadata, OpenAI-compatible request-family hints, model-catalog aliases/suppressions, OpenAI stale Spark suppression, and reusable startup metadata snapshots into plugin manifests so core no longer carries bundled-provider routing tables or repeated manifest rebuilds. Thanks @shakkernerd.</li>
<li>Plugins/config: deprecate direct plugin config load/write helpers in favor of passed runtime snapshots plus transactional mutation helpers with explicit restart follow-up policy, scanner guardrails, runtime warnings, and revision-based cache invalidation.</li>
<li>Plugins/install: allow <code>OPENCLAW_PLUGIN_STAGE_DIR</code> to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.</li>
<li>Control UI: add a raw config pending-changes diff panel that parses JSON5, redacts sensitive values until reveal, and avoids fake raw-edit callbacks when opening the panel. Refs #39831; supersedes #48621 and #46654. Thanks @JiajunBernoulli and @BunsDev.</li>
<li>Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.</li>
<li>Matrix/E2EE: add <code>openclaw matrix encryption setup</code> to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.</li>
<li>Agents/compaction: add an opt-in <code>agents.defaults.compaction.maxActiveTranscriptBytes</code> preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.</li>
<li>CLI/migration: add <code>openclaw migrate</code> with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only reports, a Claude Code/Desktop importer, and a Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, commands, and supported credentials. Thanks @vincentkoc and @NousResearch.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as <code>tsserver</code> do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.</li>
<li>Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin <code>device.token.rotate</code> responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.</li>
<li>Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev.</li>
<li>Gateway/startup: reuse config snapshot plugin manifests for startup auto-enable, config validation, and plugin bootstrap planning, including authored source config and disabled setup-probe handling, so restrictive allowlists avoid duplicate manifest/config passes during boot. Thanks @shakkernerd.</li>
<li>Agents/subagents: enforce <code>subagents.allowAgents</code> for explicit same-agent <code>sessions_spawn(agentId=...)</code> calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio.</li>
<li>ACP/sessions_spawn: let explicit <code>sessions_spawn(runtime="acp")</code> bootstrap turns run while <code>acp.dispatch.enabled=false</code> still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed.</li>
<li>CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking <code>openclaw update</code> verification. Thanks @shakkernerd.</li>
<li>Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct <code>dist/index.js</code> without hanging before logs. Fixes #72720. Thanks @sign-2025.</li>
<li>Google Meet: route local Chrome joins through OpenClaw browser control, grant Meet media permissions, pin local Chrome audio defaults to <code>BlackHole 2ch</code>, and use the configured OpenClaw browser profile so joined agents no longer show <code>Permission needed</code> or use raw/default Chrome state. Thanks @DougButdorf and @oromeis.</li>
<li>Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20.</li>
<li>Plugins/install: skip test files and directories during install security scans while still force-scanning declared runtime entrypoints, so packaged test mocks no longer block plugin installs. Fixes #66840; carries forward #67050. Thanks @saurabhjain1592 and @Magicray1217.</li>
<li>Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping <code>node_modules</code> symlinks. Carries forward #70819. Thanks @fgabelmannjr.</li>
<li>Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so <code>openclaw --profile <name> plugins install ...</code> no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402.</li>
<li>Plugins/registry: suppress duplicate-plugin startup warnings when a tracked npm-installed plugin intentionally overrides the bundled plugin with the same id. Carries forward #48673. Thanks @abdushsk.</li>
<li>Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo.</li>
<li>Gateway/proxy: pass <code>ALL_PROXY</code> / <code>all_proxy</code> into the global Undici env-proxy dispatcher and provider proxy-fetch helper while keeping SSRF trusted-proxy auto-upgrade on <code>HTTP_PROXY</code> / <code>HTTPS_PROXY</code> only, so gateway/provider calls honor all-proxy setups without weakening guarded fetches. Fixes #43821; carries forward #43919. Thanks @RickyTong1.</li>
<li>Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111.</li>
<li>Discord: persist routed model-picker overrides when the hidden <code>/model</code> dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129.</li>
<li>Nodes/CLI: add <code>openclaw nodes remove --node <id|name|ip></code> and <code>node.pair.remove</code> so stale gateway-owned node pairing records can be cleaned without hand-editing state files.</li>
<li>Gateway: include the connecting client and fresh presence version in the initial <code>hello-ok</code> snapshot, so clients no longer need a follow-up event before seeing themselves online.</li>
<li>Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the <code>bookworm-slim</code> base switch. Fixes #72787. Thanks @ryuhaneul.</li>
<li>Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge.</li>
<li>Providers/reasoning: let Groq and LM Studio declare provider-native reasoning effort values, so Qwen thinking models receive <code>none</code>/<code>default</code> or <code>off</code>/<code>on</code> instead of OpenAI-only <code>low</code>/<code>medium</code> values. Fixes #32638. Thanks @Aqu1bp, @mgoulart, @Norpps, and @BSTail.</li>
<li>Local models: default custom providers with only <code>baseUrl</code> to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive <code>/v1/chat/completions</code> without timing out. Fixes #40024. Thanks @parachuteshe.</li>
<li>Channels/message tool: surface Discord, Slack, and Mattermost <code>user:</code>/<code>channel:</code> target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding <code>user:<id></code>. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.</li>
<li>Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.</li>
<li>Agents/subagents: preserve requester delivery for completion announces across different channel accounts, keep same-channel thread completions routed to the child thread, and fail closed instead of guessing a child binding when requester conversation signal is missing. Thanks @sfuminya and @suyua9.</li>
<li>Agents/status: persist the post-compaction token estimate from auto-compaction when providers omit usage metadata, so <code>/status</code> and session lists keep showing fresh context usage after compaction. Fixes #67667; carries forward #72822. Thanks @Jimmy-xuzimo and @skylight-9.</li>
<li>Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.</li>
<li>Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let <code>openclaw doctor --fix</code> quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.</li>
<li>Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels.</li>
<li>Agents/reasoning: recover fully wrapped unclosed <code><think></code> replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y.</li>
<li>Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture.</li>
<li>Agents/fallback: split ambiguous provider failures into <code>empty_response</code>, <code>no_error_details</code>, and <code>unclassified</code>, and add flat fallback-step fields to structured fallback logs so primary-model failures stay visible when later fallbacks also fail. Fixes #71922; refs #71744. Thanks @andyk-ms and @nikolaykazakovvs-ux.</li>
<li>Plugins/Windows: normalize Windows absolute paths before handing bundled plugin modules to Jiti, so Feishu/Lark message sending no longer fails with unsupported <code>c:</code> ESM loader URLs. Fixes #72783. Thanks @jackychen-png.</li>
<li>CLI/doctor: run bundled plugin runtime-dependency repairs through the async npm installer with spinner/line progress and heartbeat updates, so long <code>openclaw doctor --fix</code> installs no longer look hung in TTY or piped output. Fixes #72775. Thanks @dfpalhano.</li>
<li>Feishu/Windows: normalize bundled channel sidecar loads before Jiti evaluates them, so Feishu outbound sends no longer fail with raw <code>C:</code> ESM loader errors on Windows. Fixes #72783. Thanks @jackychen-png.</li>
<li>Agents/tools: ignore volatile <code>exec</code> runtime metadata when comparing tool-loop outcomes, so enabled loop detection can stop repeated identical shell-command results instead of resetting on duration, PID, session, or cwd changes. Fixes #34574; supersedes #41502. Thanks @gucasbrg and @Zcg2021.</li>
<li>Agents/fallback: classify internal live-session model switch conflicts as unknown fallback failures instead of provider overloads, preventing local vLLM endpoints from receiving misleading overloaded cooldowns. Refs #63229. Thanks @clawdia-lobster.</li>
<li>Discord: let thread sessions inherit the parent channel's session-level <code>/model</code> override as a model-only fallback without enabling parent transcript inheritance. Fixes #72755. Thanks @solavrc.</li>
<li>Gateway/plugins: skip stale configured channels whose matching plugin is no longer discoverable, point cleanup at <code>openclaw doctor --fix</code>, and keep unrelated channel typos fatal so one missing channel plugin no longer crash-loops the Gateway. Fixes #53311. Thanks @futhgar.</li>
<li>Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top.</li>
<li>Agents/Qwen: preserve exact custom <code>modelstudio</code> provider configs with foreign <code>api</code> owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831.</li>
<li>MCP/bundle-mcp: normalize CLI-native <code>type: "http"</code> MCP server entries to OpenClaw <code>transport: "streamable-http"</code> on save, repair existing configs with doctor, and keep embedded Pi from falling back to legacy SSE GET-first startup for those servers. Fixes #72757. Thanks @Studioscale.</li>
<li>OpenCode: expose Anthropic Opus/Sonnet 4.x thinking levels for proxied Claude models, so <code>/think xhigh</code>, <code>/think adaptive</code>, and <code>/think max</code> validate consistently with the direct Anthropic provider. Fixes #72729. Thanks @haishmg and @aaajiao.</li>
<li>Media-understanding/audio: migrate deprecated <code>{input}</code> placeholders in legacy <code>audio.transcription.command</code> configs to <code>{{MediaPath}}</code>, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash.</li>
<li>Ollama/WSL2: warn when GPU-backed WSL2 installs combine CUDA visibility with an autostarting <code>ollama.service</code> using <code>Restart=always</code>, and document the systemd, <code>.wslconfig</code>, and keep-alive mitigation for crash loops. Carries forward #61022; fixes #61185. Thanks @yhyatt.</li>
<li>Ollama/onboarding: de-dupe suggested bare local models against installed <code>:latest</code> tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden.</li>
<li>Memory-core/doctor: keep <code>doctor.memory.status</code> on the cached path by default and only run live embedding pings for explicit deep probes, preventing slow local embedding backends from blocking Gateway status checks. Fixes #71568. Thanks @apex-system.</li>
<li>Memory/QMD: group same-source collections into one QMD search invocation when the installed QMD supports multiple <code>-c</code> filters, while keeping older QMD builds on the per-collection fallback. Fixes #72484; supersedes #72485 and #69583. Thanks @BsnizND and @zeroaltitude.</li>
<li>Memory/QMD: accept QMD status vector-count variants such as <code>Vectors = 42</code>, <code>Vectors:42</code>, and <code>Vectors: 42 embedded</code>, so <code>memory status --deep</code> no longer reports embeddings unavailable for healthy QMD wrappers. Fixes #63652; carries forward #63678. Thanks @apoapostolov and @WarrenJones.</li>
<li>Memory/QMD: skip QMD vector status probes and embedding maintenance in lexical <code>searchMode: "search"</code>, so BM25-only QMD setups on ARM do not trigger llama.cpp/Vulkan builds during status checks or embed cycles. Fixes #59234 and #67113. Thanks @PrinceOfEgypt, @Vksh07, @Snipe76, @NomLom, @t4r3e2q1-commits, and @dmak.</li>
<li>Memory/QMD: report the live watcher dirty state in memory status, so changed QMD-backed memory files show as dirty until the queued sync finishes. Fixes #60244. Thanks @xinzf.</li>
<li>Compaction: skip oversized pre-compaction checkpoint snapshots and prune duplicate long user turns from compaction input and rotated successor transcripts, preventing retry storms from being preserved across checkpoint cycles. Fixes #72780. Thanks @SweetSophia.</li>
<li>Control UI/Cron: render cron job prompts and run summaries as sanitized markdown in the dashboard, with full-width block content, safer link clicks, and no duplicate error text when a failed run has no summary. Supersedes #48504. Thanks @garethdaine.</li>
<li>Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading <code>vcontrol-ui</code> connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy.</li>
<li>Agents/reasoning: treat orphan closing reasoning tags with following answer text as a privacy boundary across delivery, history, streaming, and Control UI sanitizers so malformed local-model output cannot leak chain-of-thought text. Fixes #67092. Thanks @AnildoSilva.</li>
<li>Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so <code>memory index</code>, <code>memory status --index</code>, and <code>memory search</code> no longer start long-lived file watchers that can hit macOS <code>EMFILE</code> limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue.</li>
<li>Agents/ACP: ship the Claude ACP adapter with OpenClaw and require Claude result messages before idle can complete a prompt, preventing parent agents from waking early on long-running <code>sessions_spawn(runtime: "acp", agentId: "claude")</code> children. Fixes #72080. Thanks @siavash-saki and @iannwu.</li>
<li>CLI/tasks: route <code>tasks --json</code>, <code>tasks list --json</code>, and <code>tasks audit --json</code> through a lean JSON path so read-only task inspection no longer loads unrelated plugin/runtime command graphs. Fixes #66238. Thanks @ChuckChambers.</li>
<li>Memory-core: re-resolve the active runtime config whenever <code>memory_search</code> or <code>memory_get</code> executes, so provider changes made by <code>config.patch</code> stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.</li>
<li>WebChat: keep bare <code>/new</code> and <code>/reset</code> startup instructions out of visible chat history while preserving <code>/reset <note></code> as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.</li>
<li>Tasks/memory: checkpoint and truncate SQLite WAL sidecars on a timer and before close for task, Task Flow, proxy capture, and builtin memory databases, bounding long-running gateway <code>*.sqlite-wal</code> growth. Fixes #72774. Thanks @dfpalhano.</li>
<li>CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.</li>
<li>Control UI/Gateway: cache, coalesce, stale-refresh, and invalidate effective tool inventory on channel registry changes while reusing the gateway-bound plugin registry and avoiding model/auth discovery, so chat runs no longer stall Control UI requests on repeated plugin/model setup. Fixes #72365; supersedes #72558. Thanks @Gabiii2398 and @1yihui.</li>
<li>Channels/setup: treat bundled channel plugins as already bundled during <code>channels add</code> and onboarding, enabling them without writing redundant <code>plugins.load.paths</code> entries or path install records. Fixes #72740. Thanks @iCodePoet.</li>
<li>WhatsApp: honor gateway <code>HTTPS_PROXY</code> / <code>HTTP_PROXY</code> env vars for QR-login WebSocket connections, while respecting <code>NO_PROXY</code>, so proxied networks no longer fall back to direct <code>mmg.whatsapp.net</code> connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.</li>
<li>Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding <code>openclaw.local</code> probing conflicts and Gateway restart loops on hosts such as <code>Lobster</code> or <code>ubuntu</code>. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.</li>
<li>Agents/OpenAI-compatible: retry replay-safe empty <code>stop</code> turns once for <code>openai-completions</code> endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds, and restore <code>openclaw agent --model</code> for one-shot CLI runs. Fixes #72751. Thanks @moooV252.</li>
<li>Git hooks: skip ignored staged paths when formatting and restaging pre-commit files, so merge commits no longer abort when <code>.gitignore</code> newly ignores staged merged content. Fixes #72744. Thanks @100yenadmin.</li>
<li>Memory-core/dreaming: add a supported <code>dreaming.model</code> knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv.</li>
<li>Agents/Anthropic: remove trailing assistant prefill payloads when extended thinking is enabled, so Opus 4.7/Sonnet 4.6 requests do not fail Anthropic's user-final-turn validation. Fixes #72739. Thanks @superandylin.</li>
<li>Agents/vLLM/Qwen: add plugin-owned Qwen thinking controls for vLLM chat-template kwargs and DashScope-style top-level <code>enable_thinking</code> flags, including preserved thinking for agent loops. Fixes #72329. Thanks @stavrostzagadouris.</li>
<li>Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis.</li>
<li>Agents/exec: apply configured <code>tools.exec.timeoutSec</code> to background, <code>yieldMs</code>, and node <code>system.run</code> commands when no per-call timeout is set, preventing auto-backgrounded and remote node commands from running indefinitely. Fixes #67600; supersedes #67603. Thanks @dlmpx and @kagura-agent.</li>
<li>Config/doctor: stop masking unknown-key validation diagnostics such as <code>agents.defaults.llm</code>, and have <code>openclaw doctor --fix</code> remove the retired <code>agents.defaults.llm</code> timeout block. Thanks @aidiffuser.</li>
<li>CLI/startup: keep the built pre-dispatch CLI graph free of package-level imports and extend packaged CLI smoke coverage to onboard and doctor help paths, preventing missing runtime dependencies such as tslog from killing onboarding before repair code can run. Fixes #63024. Thanks @hu19940121.</li>
<li>CLI/plugins: preserve unversioned ClawHub install specs so <code>plugins update</code> can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt.</li>
<li>Memory-core/subagents: tag plugin-created subagent sessions with their plugin owner so dreaming narrative cleanup can delete its own ephemeral sessions without granting broad admin session deletion. Fixes #72712. Thanks @BSG2000.</li>
<li>Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables.</li>
<li>CLI/update: honor <code>OPENCLAW_NO_AUTO_UPDATE=1</code> as a gateway startup kill-switch for configured background package auto-updates, so operators can hold a deliberate downgrade during incident recovery without editing config first. Fixes #72715. Thanks @Xivi08.</li>
<li>Agents/Claude CLI: force live-session launches to include <code>--output-format stream-json</code> whenever OpenClaw adds <code>--input-format stream-json</code>, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08.</li>
<li>CLI/plugins: accept ClawHub plugin API wildcard ranges such as <code>*</code> without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo.</li>
<li>CLI/plugins: add an explicit <code>npm:<package></code> install prefix that skips ClawHub lookup for known npm packages while keeping bare package specs ClawHub-first. Fixes #55805; supersedes #54377. Thanks @Zeoy2020 and @vagusX.</li>
<li>CLI/plugins: let config-gated bundled plugins install without persisting invalid placeholder config entries, so install/uninstall sweeps can cover plugins such as memory-lancedb before the user configures credentials. Thanks @vincentkoc.</li>
<li>CLI/plugins: reject malformed ClawHub plugin specs with trailing <code>@</code> before registry lookup, so empty-version typos report as invalid specs instead of package-not-found errors. Fixes #56579; supersedes #56582. Thanks @Kansodata.</li>
<li>Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work.</li>
<li>Browser/plugins: auto-start the bundled browser plugin when root <code>browser</code> config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist.</li>
<li>Browser: circuit-break repeated managed Chrome launch failures per profile so browser requests stop spawning Chromium indefinitely when CDP cannot start. Fixes #64271. Thanks @TheophilusChinomona.</li>
<li>Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers.</li>
<li>CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody.</li>
<li>Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw.</li>
<li>Agents/Google: strip stale trailing assistant/model prefill turns from Gemini outbound replay so Google Generative AI requests end with a user turn or function response. Follow-up to #72556. Thanks @Veda-openclaw.</li>
<li>Control UI/Dreaming: require explicit confirmation before applying restart-impacting Dreaming mode changes, with restart warning copy and loading feedback. Fixes #63804. (#63807) Thanks @bbddbb1.</li>
<li>CLI/agent: mark Gateway-to-embedded fallback runs with <code>meta.transport: "embedded"</code> and <code>meta.fallbackFrom: "gateway"</code> in JSON output, and make the terminal diagnostic explicit so scripts and operators can distinguish fallback runs from Gateway runs. Fixes #71416. Thanks @amknight.</li>
<li>Agents/tools: normalize <code>null</code> or missing tool-call arguments to <code>{}</code> for parameterless object schemas before Pi validation, so empty-argument tools run instead of failing argument validation. Fixes #72587. Thanks @amknight.</li>
<li>Agents/subagents: clear active embedded-run state before terminal lifecycle events so post-completion cleanup no longer treats finished child runs as still active and skips archive or announcement bookkeeping. (#70187) Thanks @amknight.</li>
<li>CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding <code>.24</code> update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin.</li>
<li>Control UI: make explicit Reload Config actions discard stale local config edits while passive refreshes and failed-save recovery keep pending drafts intact. Fixes #40352; carries forward #40443. Thanks @realmikechong-dotcom.</li>
<li>Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock <code>ContentBlock</code> blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu.</li>
<li>Agents/LM Studio: promote standalone bracketed local-model tool requests into registered tool calls and hide unsupported bracket blocks from visible replies, so MemPalace MCP lookups do not print raw <code>[tool]</code> JSON scaffolding in chat. Fixes #66178. Thanks @detroit357.</li>
<li>Local models: warn when an assistant reply looks like a tool call but the provider emitted plain text instead of a structured tool invocation, making fake/non-executed tool calls visible in logs. Fixes #51332. Thanks @emilclaw.</li>
<li>Local models: accept persisted non-secret local auth markers for private-LAN custom OpenAI-compatible providers, so LAN Ollama configs no longer fail with missing auth when <code>ollama-local</code> is saved as the key. Fixes #49736. Thanks @charles-zh.</li>
<li>TUI/local models: treat visible gateway client labels such as <code>openclaw-tui</code> as the current requester session for session-aware tools, so Ollama tool calls no longer fail by resolving the UI label as a session id. Fixes #66391. Thanks @kickingzebra.</li>
<li>Local models: route self-hosted OpenAI-compatible model discovery through the guarded fetch path pinned to the configured host, covering vLLM and SGLang setup without reopening local/LAN SSRF probes. Supersedes #46359. Thanks @cdxiaodong.</li>
<li>Local models: classify terminated, reset, closed, timeout, and aborted model-call failures and attach a process memory snapshot to the diagnostic event, making LM Studio/Ollama RAM-pressure failures easier to prove from stability bundles. Refs #65551. Thanks @BigWiLLi111.</li>
<li>Local models: pass configured provider request timeouts through OpenAI SDK transports and the model idle watchdog so long-running local or custom OpenAI-compatible streams use one timeout knob instead of hitting the SDK's 10-minute default or the 120s idle default. Fixes #63663. Thanks @aidiffuser.</li>
<li>LM Studio: trust configured LM Studio loopback, LAN, and tailnet endpoints for guarded model requests by default, preserving explicit private-network opt-outs. Refs #60994. Thanks @tnowakow.</li>
<li>Docker/setup: route Docker onboarding defaults for host-side LM Studio and Ollama through <code>host.docker.internal</code> and add the Linux host-gateway mapping to the bundled Compose file, so containerized gateways can reach local providers without using container loopback. Fixes #68684; supersedes #68702. Thanks @safrano9999 and @skolez.</li>
<li>Agents/LM Studio: strip prior-turn Gemma 4 reasoning from OpenAI-compatible replay while preserving active tool-call continuation reasoning. Fixes #68704. Thanks @chip-snomo and @Kailigithub.</li>
<li>LM Studio: allow interactive onboarding to leave the API key blank for unauthenticated local servers, using local synthetic auth while clearing stale LM Studio auth profiles. Fixes #66937. Thanks @olamedia.</li>
<li>Plugins/startup/registry: reuse a Gateway <code>PluginLookUpTable</code> and one manifest registry pass across startup plugin IDs, plugin loading, deferred channel reloads, model pricing, read-only channel defaults, capability/provider/media resolution, manifest contracts, extractors, web fallback discovery, owner maps, and cold provider-discovery caches, with new startup-trace timing/count metrics for installed-index, manifest, startup-plan, and owner-map work. Thanks @shakkernerd and @mcaxtr.</li>
<li>Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda.</li>
<li>Docker: pre-create <code>/home/node/.openclaw</code> with node ownership and private permissions so first-run Docker Compose named volumes no longer fail startup with EACCES. (#48072, #63959; fixes #61279) Thanks @timoxue and @jeanibarz.</li>
<li>CLI/Gateway: treat local restart probe policy closes for connect, exact <code>device required</code>, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool.</li>
<li>Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw.</li>
<li>Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear.</li>
<li>Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled.</li>
<li>Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.</li>
<li>Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when <code>AGENTS.md</code>, <code>SOUL.md</code>, <code>MEMORY.md</code>, or <code>TOOLS.md</code> change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.</li>
<li>macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.</li>
<li>Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing <code>tslog</code>. Fixes #72228; supersedes #72493. Thanks @deepujain.</li>
<li>Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.</li>
<li>Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng.</li>
<li>Agents/Windows: normalize lazy agent runtime imports before Node ESM loading so Windows drive-letter <code>subagent-registry</code> runtime paths no longer fail every agent task with <code>ERR_UNSUPPORTED_ESM_URL_SCHEME</code>. Fixes #72636; carries forward #72716. Thanks @Andyz-CData and @xialonglee.</li>
<li>Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with <code>ERR_UNSUPPORTED_ESM_URL_SCHEME</code>. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU.</li>
<li>Browser/plugins: load <code>playwright-core</code> through the browser runtime shim so packaged installs can run Playwright actions from staged plugin runtime deps after doctor/startup repair. Fixes #72168; supersedes #72238. Thanks @zdg1110 and @yetval.</li>
<li>Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss.</li>
<li>TTS/SecretRef: resolve <code>messages.tts.providers.*.apiKey</code> from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.</li>
<li>Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when <code>systemctl --user</code> reports no-medium bus failures, without letting stale <code>SUDO_USER</code> override <code>sudo -u</code> installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.</li>
<li>CLI/nodes: make unfiltered <code>openclaw nodes list</code> prefer the effective paired-node view used by <code>nodes status</code> while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412.</li>
<li>CLI/startup: read generated startup metadata from the bundled <code>dist</code> layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.</li>
<li>Feishu/Lark: stop treating broadcast-only <code>@all</code>/<code>@_all</code> messages as bot mentions while preserving direct bot mentions, including messages that also include <code>@all</code>. Fixes #37706. Thanks @JosepLee.</li>
<li>CLI/help: treat positional <code>help</code> invocations like <code>openclaw channels help</code> as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as <code>openclaw docs help</code>. Thanks @gumadeiras.</li>
<li>Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including <code>plugins.entries.brave.config.webSearch.apiKey</code>. Fixes #68690. Thanks @VACInc.</li>
<li>Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant.</li>
<li>Google Meet/Voice Call: clean stale chrome-node realtime bridges before rejoining, expose bridge inspection, tolerate transient node input pull failures, default Chrome command-pair audio to 24 kHz PCM16 while preserving legacy 8 kHz G.711 mu-law pairs, handle Gemini Live interruptions/VAD and function-response names correctly, route stateful <code>google_meet</code> tools through the gateway runtime, support <code>realtime.agentId</code>, and send non-blocking consult continuations before long tool-backed answers finish. Fixes #72371, #72525, #72523, #72440, and #72425; (#72372, #72524, #72381, #72441, #72189, #72426) Thanks @BsnizND and @VACInc.</li>
<li>Discord/media: keep incidental Markdown image badges in final replies as text unless a channel opts into Markdown-image media extraction, while preserving Telegram Markdown-image media replies and explicit <code>MEDIA:</code> attachments. Fixes #72642. Thanks @solavrc and @Bartok9.</li>
<li>Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.</li>
<li>Cron: apply <code>cron.maxConcurrentRuns</code> to the nested isolated-agent lane, start isolated execution timeouts only after the runner enters that lane, keep legacy flat <code>jobs.json</code> rows loadable, invalidate stale pending runtime slots after schedule edits, and preserve due slots for formatting-only rewrites. Fixes #72707, #27996, #71607, and #41783; carries forward #71651. Thanks @kagura-agent, @xialonglee, @fagnersouza666, @ayanesakura, and @Hurray0.</li>
<li>Cron/delivery: classify isolated successes, quiet <code>NO_REPLY</code> turns, model/provider failures, execution denials, <code>--no-deliver</code> traces, skipped-job alerts, and verified delivery outcomes correctly so cron history, retries, and failure counters reflect what actually happened. Fixes #72732, #50170, #43604, #68452, #60846, #72210, and #67172; follow-up to #54188; carries forward #43631, #68453, #72219, and #67186. Thanks @zNatix, @pixeldyn, @ChickenEggRoll, @SPFAdvisors, @anyech, @slideshow-dingo, @hatemclawbot-collab, @xydigit-sj, @oc-gh-dr, @hclsys, and @1yihui.</li>
<li>Cron/routing: preserve direct Telegram thread/account IDs, explicit Discord <code>user:</code>/<code>channel:</code> delivery targets, and <code>session:<id></code> failure-destination routing so reminders, cron announcements, and failure alerts keep the intended recipient kind across direct and group chats. Fixes #44270; refs #62777; carries forward #44325, #44351, #44412, #72657, #68535, and #62798. Thanks @RunMintOn, @arkyu2077, @0xsline, @vincentkoc, @slideshow-dingo, @likewen-tech, and @neeravmakwana.</li>
<li>Subagents: keep the delegated task only in the subagent system prompt and send a short initial kickoff message, avoiding duplicate task tokens while preserving multiline task formatting. Fixes #72019; carries forward #72053. Thanks @Wizongod and @ly85206559.</li>
<li>Onboarding/GitHub Copilot: add manifest-owned <code>--github-copilot-token</code> support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.</li>
<li>Gateway/install: add a validated <code>--wrapper</code>/<code>OPENCLAW_WRAPPER</code> service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun <code>ProgramArguments</code>. Fixes #69400. (#72445) Thanks @willtmc.</li>
<li>Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @amknight.</li>
<li>macOS Gateway: write launchd services with a state-dir <code>WorkingDirectory</code>, use a durable state-dir temp path instead of freezing macOS session <code>TMPDIR</code>, create that temp directory before bootstrap, and label abort-shaped launchd exits as <code>SIGABRT/abort</code> in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.</li>
<li>Control UI/update: make <code>Update now</code> require a real gateway process replacement, report skipped/error update outcomes with stable reasons, and verify the running gateway version after restart so global installs cannot silently keep old code in memory. Fixes #62492; addresses #64892 and #63562. Thanks @IAMSamuelRodda.</li>
<li>Exec approvals: accept runtime-owned <code>source: "allow-always"</code> and <code>commandText</code> allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with <code>unexpected property 'source'</code>. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang.</li>
<li>Exec/node: skip approval-plan preparation for full-trust <code>host=node</code> runs so interpreter and script commands no longer fail with <code>SYSTEM_RUN_DENIED: approval cannot safely bind</code> when effective policy is <code>security=full</code> and <code>ask=off</code>. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC.</li>
<li>Exec/node: synthesize a local approval plan when a paired node advertises <code>system.run</code> without <code>system.run.prepare</code>, unblocking approval-required <code>host=node</code> exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz.</li>
<li>Memory/QMD: prefer QMD's <code>--mask</code> collection pattern flag so root memory indexing stays scoped to <code>MEMORY.md</code> instead of widening to every markdown file in the workspace. Fixes #65480; supersedes #65481 and #66259. Thanks @ccage-simp, @Bortlesboat, @seank-com, and @crazyscience.</li>
<li>Memory/doctor: treat the specific <code>gateway timeout after ...</code> gateway memory probe result as inconclusive instead of reporting embeddings not ready, while preserving warnings for explicit failures. Fixes #44426; carries forward #46576 with the Greptile review feedback applied. Thanks Cengiz (@ghost).</li>
<li>Gateway/startup: defer QMD, core request handlers, setup wizard, CLI outbound senders, plugin HTTP routes, chat/session projection, node session runtime validation, embedded-run activity reads, MCP loopback server imports, channel runtime helpers, HTTP/canvas/plugin auth helpers, isolated cron imports, and hook dispatch parsing until their request or shutdown paths, while making plain <code>gateway status</code> use a parse-only config snapshot so no-plugin boots and status reads avoid broad runtime fanout. Thanks @vincentkoc.</li>
<li>Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and <code>llm.invoke</code> loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc.</li>
<li>Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in <code>/status</code>, <code>session_status</code>, or persisted <code>sessionEntry.totalTokens</code>. Fixes #69298. Thanks @richardmqq.</li>
<li>Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.</li>
<li>Gateway health: preserve live runtime-backed channel/account state in <code>gateway.health</code> snapshots and cached refreshes while keeping raw probe payloads on sensitive/admin paths only. (#39921, #42586, #46527, #52770, #42543) Thanks @FAL1989, @rstar327, @0xble, and @ajayr.</li>
<li>Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.</li>
<li>Telegram/agents: hide raw failed write/edit warning messages in Telegram when the assistant already explicitly acknowledges the failed action, while keeping warnings when the reply claims success or omits the failure; #39406 remains the broader configurable delivery-policy follow-up. Fixes #51065; covers #39631. Thanks @Bartok9 and @Bortlesboat.</li>
<li>Exec approvals: accept a symlinked <code>OPENCLAW_HOME</code> as the trusted approvals root while still rejecting symlinked <code>.openclaw</code> path components below it. (#64663) Thanks @FunJim.</li>
<li>Logging: add top-level <code>hostname</code>, flattened <code>message</code>, and available <code>agent_id</code>, <code>session_id</code>, and <code>channel</code> fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.</li>
<li>ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.</li>
<li>Logging: propagate internal request trace scopes through Gateway HTTP requests and WebSocket frames so file logs, diagnostic events, agent run traces, model-call traces, OTEL spans, and trusted provider <code>traceparent</code> headers share a correlatable <code>traceId</code> without logging raw request or model content. Fixes #40353. Thanks @liangruochong44-ui.</li>
<li>Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.</li>
<li>Logging: write validated diagnostic trace context as top-level <code>traceId</code>, <code>spanId</code>, <code>parentSpanId</code>, and <code>traceFlags</code> fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.</li>
<li>Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.</li>
<li>Agents/sessions: let <code>sessions_spawn runtime="subagent"</code> ignore ACP-only <code>streamTo</code> and <code>resumeSessionId</code> fields while keeping ACP passthrough and documenting <code>streamTo</code> as ACP-only. Fixes #43556 and #63120; covers #56326, #61724, #64714, and #67248; carries forward #68397, #65282, #58686, #56342, and #40102. Thanks @skernelx, @damselem, @Br1an67, @Mintalix, @IsaacAPerez, @vvitovec, @Sanjays2402, @shenkq97, and @1034378361.</li>
<li>Providers/Ollama: honor <code>/api/show</code> capabilities, custom Modelfile <code>PARAMETER num_ctx</code>, configured provider/model context defaults, whitelisted native params such as <code>temperature</code>, <code>top_p</code>, and <code>think</code>, and native thinking effort levels so local models get accurate tools, context, and thinking behavior without forcing full-context VRAM use. Fixes #64710, duplicate #65343, #68344, #44550, #52206, #49684, #68662, #48010, #71584, and #44786; supersedes #69464; carries forward #44955. Thanks @yuan-b, @netherby, @xilopaint, @Diyforfun2026, @neeravmakwana, @taitruong, @armi0024, @LokiCode404, @zhouZcong, @dshenster-byte, @tangzhi, @pandego, @maweibin, @Adam-Researchh, @EmpireCreator, @g0st1n, and @voltwake.</li>
<li>Image tool/media: honor <code>tools.media.image.timeoutSeconds</code> and matching per-model image timeouts in explicit image analysis, including the MiniMax VLM fallback path, so slow local vision models are not capped by hardcoded 30s/60s aborts. Fixes #67889; supersedes #67929. Thanks @AllenT22 and @alchip.</li>
<li>Providers/Ollama: strip custom provider prefixes before native chat/embedding requests, skip ambient localhost discovery unless config/auth opts in, handle custom remote <code>api: "ollama"</code> providers, accept OpenAI SDK-style <code>baseURL</code>, scope synthetic local auth and embedding bearer headers to declared host boundaries, resolve custom-named local providers for subagents, add provider-scoped model request timeouts, preserve explicit input modalities, and document <code>params.keep_alive</code> plus local/LAN/cloud/multi-host/web-search/embedding/thinking setup recipes. Fixes #72353, #56939, #62533, #43945, #64541, #68796, and #39690; supersedes #57116, #62549, #69261, #69857, #65143, and #66511; refs #43945; carries forward #43224 and #39785. Thanks @maximus-dss, @hclsys, @IanxDev, @tsukhani, @issacthekaylon, @Julien-BKK, @Linux2010, @hyspacex, @maxramsay, @Meli73, @LittleJakub, @Juankcba, @uninhibite-scholar, @yfge, @Skrblik, and @Mriris.</li>
<li>Providers/Ollama: move memory embeddings to <code>/api/embed</code> with batched <code>input</code>, route local web search through Ollama's signed daemon proxy while keeping cloud auth scoped, treat Ollama memory embeddings as key-optional in doctor, and keep model usage visible by estimating native transcript usage when <code>/api/chat</code> omits counters. Fixes #39983, #69132, and #46584; carries forward #39112. Thanks @sskkcc, @LiudengZhang, @yoon1012, @hyspacex, @fengly78, and @TylonHH.</li>
<li>Agents/Ollama: parse stringified native tool-call arguments, retry native empty/thinking-only turns, accept already-prefixed LLM task model overrides, apply provider-owned replay normalization for Cloud models, validate explicit <code>--thinking max</code>, show resolved thinking defaults in Control UI, and include configured provider models in <code>models list --provider</code>. Fixes #69735, #50052, #71697, #71584, #72407, and #65207; supersedes #69910; carries forward #66552 and #61223. Thanks @rongshuzhao, @yfge, @L3G, @ralphy-maplebots, @Hollychou924, @ismael-81, @g0st1n, @NotecAG, and @drzeast-png.</li>
<li>Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077.</li>
<li>Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.</li>
<li>Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.</li>
<li>Agents/model fallback: keep auto-persisted fallback model overrides selected across turns until <code>/new</code> or reset clears them, avoiding repeated probes of a known-bad primary while <code>/status</code> shows the selected and active models. Thanks @kibedu.</li>
<li>Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.</li>
<li>Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.</li>
<li>Plugins/startup: load the default <code>memory-core</code> slot during Gateway startup when permitted so active-memory recall can call <code>memory_search</code> and <code>memory_get</code> without requiring an explicit <code>plugins.slots.memory</code> entry, while preserving <code>plugins.slots.memory: "none"</code>.</li>
<li>Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.</li>
<li>Plugins/compat/CLI: inventory doctor-side deprecation migrations separately from runtime plugin compatibility, add dated records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims, refresh the persisted registry after managed plugin removals, make plugin install/uninstall writes conflict-aware, clear stale denylists, and fail tracked plugin/hook updates or unloadable package installs instead of leaving stale state. Thanks @vincentkoc.</li>
<li>WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.</li>
<li>Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.</li>
<li>Gateway/chat: keep duplicate attachment-backed <code>chat.send</code> retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.</li>
<li>Gateway/session rows: report the same config-resolved thinking default that runtime sessions use, including global and per-agent defaults, so Control UI and TUI default labels stay aligned. (#71779, #70981, #71033, #70302) Thanks @chen-zhang-cs-code, @SymbolStar, and @cholaolu-boop.</li>
<li>Plugins: share package entrypoint resolution between install and discovery, reject mismatched <code>runtimeExtensions</code>, and cache bundled runtime-dependency manifest reads during scans.</li>
<li>WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.</li>
<li>Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.</li>
<li>Cron/context engine: run isolated cron jobs under run-scoped context-engine session keys so prior runs of the same job are not inherited unless the job is explicitly session-bound. (#72292) Thanks @jalehman.</li>
<li>Control UI: localize command palette labels, categories, skill shortcuts, footer hints, and connect-command copy labels while preserving localized command palette search matching. (#61130, #61119) Thanks @rubensfox20.</li>
<li>Plugins/memory-lancedb: request float embedding responses from OpenAI-compatible servers so local providers that default SDK requests to base64 no longer return dimension-mismatched LanceDB vectors while preserving configured dimensions. Fixes #45982. (#59048, #46069, #45986) Thanks @deep-introspection, @xiaokhkh, @caicongyang, and @thiswind.</li>
<li>Plugins/memory-lancedb: advance auto-capture cursors per session only after messages are processed or intentionally skipped, retry failed messages, survive compacted histories, and clear cursor state on session end. Fixes #71349; carries forward #42083. Thanks @as775116191.</li>
<li>Plugins/memory-core: respect configured memory-search embedding concurrency during non-batch indexing so local Ollama embedding backends can serialize indexing instead of flooding the server. Fixes #66822. (#66931) Thanks @oliviareid-svg and @LyraInTheFlesh.</li>
<li>Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.</li>
<li>Gateway/models: repair legacy <code>models.providers.*.api = "openai"</code> config values to <code>openai-completions</code>, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.</li>
<li>Gateway/skills: redact <code>apiKey</code> and secret-named <code>env</code> values from the <code>skills.update</code> RPC response to prevent leaking credentials into WebSocket traffic, client logs, or session transcripts. Config is still written to disk in full; only the response payload is redacted. (#69998) Thanks @Ziy1-Tan.</li>
<li>Plugins/CLI: let flag-driven <code>openclaw channels add</code> install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.</li>
<li>Onboarding/setup: keep first-run config reads, plugin compatibility notices, OpenAI Codex auth, post-auth default-model policy lookup, skip-auth, provider-scoped model pickers, and post-model sanity checks on cold manifest/setup metadata unless the user chooses to browse all models, avoiding full plugin/provider runtime loads between prompts. Thanks @shakkernerd.</li>
<li>Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.</li>
<li>Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.</li>
<li>Telegram: send a fresh final message for long-lived preview-streamed replies so the visible Telegram timestamp reflects completion time instead of the preview creation time. Thanks @rubencu.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.26/OpenClaw-2026.4.26.zip" length="48222029" type="application/octet-stream" sparkle:edSignature="6wgFZUyyU09Y6nvD9T1Ufq7Plo0Wzfg+L9r80DCaNMMuwebcKWAsMVSP3RvhRhTxVMax8toUDYg3gb/vOiE5BA=="/>
</item>
<item>
<title>2026.4.25</title>
<pubDate>Mon, 27 Apr 2026 13:34:25 +0000</pubDate>
@@ -454,206 +689,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.24/OpenClaw-2026.4.24.zip" length="48033180" type="application/octet-stream" sparkle:edSignature="wxOfxadSZ/9iXMitaC6SA9J6YPZC3P2tkeK7HZPHzjUIlzQTvOl7EjR4aRyXzaYt1N1AK5ba+YhuCwEngrTdCQ=="/>
</item>
<item>
<title>2026.4.22</title>
<pubDate>Thu, 23 Apr 2026 15:18:00 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042290</sparkle:version>
<sparkle:shortVersionString>2026.4.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.22</h2>
<h3>Changes</h3>
<ul>
<li>Providers/xAI: add image generation, text-to-speech, and speech-to-text support, including <code>grok-imagine-image</code> / <code>grok-imagine-image-pro</code>, reference-image edits, six live xAI voices, MP3/WAV/PCM/G.711 TTS formats, <code>grok-stt</code> audio transcription, and xAI realtime transcription for Voice Call streaming. (#68694) Thanks @KateWilkins.</li>
<li>Providers/STT: add Voice Call streaming transcription for Deepgram, ElevenLabs, and Mistral, alongside the existing OpenAI and xAI realtime STT paths; ElevenLabs also gains Scribe v2 batch audio transcription for inbound media.</li>
<li>TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.</li>
<li>Onboarding: auto-install missing provider and channel plugins during setup so first-run configuration can complete without manual plugin recovery.</li>
<li>OpenAI/Responses: use OpenAI's native <code>web_search</code> tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed <code>web_search</code> tool.</li>
<li>Models/commands: add <code>/models add <provider> <modelId></code> so you can register a model from chat and use it without restarting the gateway; keep <code>/models</code> as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman.</li>
<li>WhatsApp: add configurable native reply quoting with replyToMode for WhatsApp conversations. Thanks @mcaxtr.</li>
<li>WhatsApp/groups+direct: forward per-group and per-direct <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> so configured per-chat behavioral instructions are injected on every turn. Supports <code>"*"</code> wildcard fallback and account-scoped overrides under <code>channels.whatsapp.accounts.<id>.{groups,direct}</code>; account maps fully replace root maps (no deep merge), matching the existing <code>requireMention</code> pattern. Closes #7011. (#59553) Thanks @Bluetegu.</li>
<li>Agents/sessions: add mailbox-style <code>sessions_list</code> filters for label, agent, and search plus visibility-scoped derived title and last-message previews. (#69839) Thanks @dangoZhang.</li>
<li>Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.</li>
<li>Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.</li>
<li>Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub onboarding, docs, <code>hy3-preview</code> model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.</li>
<li>Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo.</li>
<li>Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add <code>agents.defaults.promptOverlays.gpt5.personality</code> as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback.</li>
<li>Providers/OpenAI Codex: remove the Codex CLI auth import path from onboarding and provider discovery so OpenClaw no longer copies <code>~/.codex</code> OAuth material into agent auth stores; use browser login or device pairing instead. (#70390) Thanks @pashpashpash.</li>
<li>CLI/Claude: default <code>claude-cli</code> runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.</li>
<li>Pi/models: update the bundled pi packages to <code>0.68.1</code> and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed <code>opencode-go/kimi-k2.6</code>, Qwen, GLM, MiMo, and MiniMax entries.</li>
<li>Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy <code>exec</code> and <code>bash</code> tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.</li>
<li>ACPX: add an explicit <code>openClawToolsMcpBridge</code> option that injects a core OpenClaw MCP server for selected built-in tools, starting with <code>cron</code>.</li>
<li>CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin <code>dist/*</code> runtime entries over source-adjacent JavaScript fallbacks, reducing the measured <code>doctor --non-interactive</code> runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.</li>
<li>CLI/debugging: add an opt-in temporary debug timing helper for local CLI performance investigations, with readable stderr output, JSONL capture, and docs for removing probes before landing fixes. (#70469) Thanks @shakkernerd.</li>
<li>Docs/i18n: add Thai translation support for the docs site.</li>
<li>Providers/OpenAI-compatible: mark known local backends such as vLLM, SGLang, llama.cpp, LM Studio, LocalAI, Jan, TabbyAPI, and text-generation-webui as streaming-usage compatible, so their token accounting no longer degrades to unknown/stale totals. (#68711) Thanks @gaineyllc.</li>
<li>Providers/OpenAI-compatible: recover streamed token usage from llama.cpp-style <code>timings.prompt_n</code> / <code>timings.predicted_n</code> metadata and sanitize usage counts before accumulation, fixing unknown or stale totals when compatible servers do not emit an OpenAI-shaped <code>usage</code> object. (#41056) Thanks @xaeon2026.</li>
<li>Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.</li>
<li>Plugin SDK/STT: share realtime transcription WebSocket transport and multipart batch transcription form helpers across bundled STT providers, reducing provider plugin boilerplate while preserving proxy capture, reconnects, audio queueing, close flushing, upload filename normalization, and ready handshakes.</li>
<li>Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as <code>tool_result</code> handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.</li>
<li>Codex harness/hooks: route native Codex app-server turns through <code>before_prompt_build</code> and emit <code>before_compaction</code> / <code>after_compaction</code> for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async <code>tool_result</code> middleware, fire <code>after_tool_call</code> for Codex tool runs, and route mirrored Codex transcript writes through <code>before_message_write</code> so tool integrations stop diverging from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/hooks: fire <code>llm_input</code>, <code>llm_output</code>, and <code>agent_end</code> for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.</li>
<li>Status: add an explicit <code>Runner:</code> field to <code>/status</code> so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as <code>codex (acp/acpx)</code> or <code>gemini (acp/acpx)</code>. (#70595)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy <code>off</code>/<code>low</code> fallback behavior to a safe provider-supported <code>medium</code> equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make <code>/status</code> report the same resolved default as runtime.</li>
<li>Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses.</li>
<li>Agents/sessions: keep daily reset and idle-maintenance bookkeeping from bumping session activity or pruning freshly active routes, so active conversations no longer look newer or disappear for maintenance-only updates.</li>
<li>Plugins/install: add newly installed plugin ids to an existing <code>plugins.allow</code> list before enabling them, so allowlisted configs load installed plugins after restart.</li>
<li>Status: show <code>Fast</code> in <code>/status</code> when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.</li>
<li>OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure <code>api-key</code> auth plus deployment-scoped image URLs, honor <code>AZURE_OPENAI_API_VERSION</code>, and document the Azure setup path so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.</li>
<li>Telegram/forum topics: cache recovered forum metadata with bounded expiry so supergroup updates no longer need repeated <code>getChat</code> lookups before topic routing.</li>
<li>Onboarding/WeCom: show the official WeCom channel plugin with its native Enterprise WeChat display name and blurb in the external channel catalog.</li>
<li>Models/auth: merge provider-owned default-model additions from <code>openclaw models auth login</code> instead of replacing <code>agents.defaults.models</code>, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with <code>replaceDefaultModels</code>. Fixes #69414. (#70435) Thanks @neeravmakwana.</li>
<li>Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in <code>tools.media.audio</code> auto mode. Fixes #68727.</li>
<li>Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.</li>
<li>Agents/BTW: route <code>/btw</code> side questions through provider stream registration with the session workspace, so Ollama provider URL construction and workspace-scoped hooks apply correctly. Fixes #68336. (#70413) Thanks @suboss87.</li>
<li>Agents/sessions: make session transcript write locks non-reentrant by default, so same-process transcript writers contend unless a helper explicitly opts into nested lock ownership.</li>
<li>ACPX/probe: expose an optional <code>probeAgent</code> plugin config field so the embedded ACP runtime health probe can target a configured agent (for example <code>opencode</code> or <code>claude</code>) instead of hardcoding <code>codex</code>, and stop marking the entire ACP runtime backend unavailable when the default probe agent is simply not installed or not authenticated. (#68409) Thanks @lyfuci.</li>
<li>Memory search: use sqlite-vec KNN for vector recall while preserving full post-filter result limits in multi-model indexes. Fixes #69666. (#69680) Thanks @aalekh-sarvam.</li>
<li>Providers/OpenAI Codex: stop stale per-agent <code>openai-codex:default</code> OAuth profiles from shadowing a newer main-agent identity-scoped profile, and let <code>openclaw doctor</code> offer the matching cleanup. (#70393) Thanks @pashpashpash.</li>
<li>ACPX: route OpenClaw ACP bridge commands through the MCP-free runtime path even when the command is wrapped with <code>env</code>, has bridge flags, or is resumed from persisted session state, so documented <code>acpx openclaw</code> setups no longer fail on per-session MCP injection. (#68741) Thanks @alexlomt.</li>
<li>Codex harness: route Codex-tagged MCP tool approval elicitations through OpenClaw plugin approvals, including current empty-schema app-server requests, while leaving generic user-input prompts fail-closed. (#68807) Thanks @kesslerio.</li>
<li>WhatsApp/outbound: hold an in-memory active-delivery claim while a live outbound send is in flight, so a concurrent reconnect drain no longer re-drives the same pending queue entry and duplicates cron sends 7-12x after the 30-minute inbound-silence watchdog fires mid-delivery. Crash-replay of fresh queue entries left behind by a dead process is preserved because the claim is intentionally process-local. Fixes #70386. (#70428) Thanks @neeravmakwana.</li>
<li>Matrix/commands: keep Matrix DM allowlist state out of room control-command authorization, so trusted DM senders do not accidentally gain room-command access.</li>
<li>Providers/SDK retry: cap long <code>Retry-After</code> sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.</li>
<li>Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken <code>MEDIA:</code> or voice tags as delivery metadata. (#68869) Thanks @zqchris.</li>
<li>Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.</li>
<li>Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (<code>stopReason=stop</code>). Real failure modes (tool errors, provider <code>stopReason=error</code>, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.</li>
<li>Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny <code>/bin/sh</code> shim that raises the child's own <code>oom_score_adj</code> on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with <code>OPENCLAW_CHILD_OOM_SCORE_ADJ=0</code>. Fixes #70404. (#70419) Thanks @neeravmakwana.</li>
<li>Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like <code>functions.<name>:<index></code>) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a <code>sanitizeToolCallIds</code> opt-out to the shared <code>openai-compatible</code> replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.</li>
<li>Dependencies/security: override transitive <code>uuid</code> to <code>14.0.0</code>, clearing the runtime advisory across dependencies.</li>
<li>Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.</li>
<li>Codex harness: expose the Codex app-server model catalog in <code>models list/status</code>, avoid startup hangs from app-server discovery timeouts, and accept current Codex turn-completion notifications so Docker live gateway turns finish reliably.</li>
<li>Codex harness: drop invalid legacy app-server <code>serviceTier</code> values such as <code>"priority"</code> before native thread and turn requests, while keeping supported Codex tiers limited to <code>"fast"</code> and <code>"flex"</code>. Fixes #64815.</li>
<li>Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.</li>
<li>Docs/Codex harness: narrow native compaction docs to the current start/completion signals, without promising a readable summary or kept-entry audit list yet. (#69612) Thanks @91wan.</li>
<li>Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.</li>
<li>Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.</li>
<li>Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so <code>plugins install</code> and <code>plugins update</code> update an included <code>plugins.json5</code> file instead of flattening modular <code>$include</code> configs. Fixes #41050 and #66048.</li>
<li>Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.</li>
<li>Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed <code>plugins install</code> attempts at <code>plugins update</code> / <code>--force</code> instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.</li>
<li>Agents/MCP: keep <code>mcp.servers</code> and bundle MCP tools available in Pi embedded <code>coding</code> and <code>messaging</code> sessions while preserving <code>minimal</code> profile and <code>tools.deny: ["bundle-mcp"]</code> opt-out behavior. Fixes #68875 and #68818.</li>
<li>Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable.</li>
<li>CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125.</li>
<li>Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn.</li>
<li>Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new <code>Authorization</code> header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.</li>
<li>Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs.</li>
<li>Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding <code>config set --merge</code> for additive updates and <code>--replace</code> for intentional clobbers. Fixes #65920, #68392, and #68653.</li>
<li>Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.</li>
<li>Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing <code>gateway.mode</code>, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.</li>
<li>Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or <code>openclaw doctor --fix</code>, preserving the clobbered file as a backup while leaving normal config reads read-only.</li>
<li>Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.</li>
<li>Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into <code>createAgentSession</code>. Thanks @vincentkoc.</li>
<li>Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom <code>models.providers.openai.baseUrl</code> endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.</li>
<li>Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron <code>deleteAfterRun</code> fallback cleanup. Fixes #69145, #68623, and #68827.</li>
<li>MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465.</li>
<li>Agents/MCP: retire bundled MCP runtimes after completed one-shot subagent cleanup and nested <code>sessions_send</code> steps, while keeping persistent subagent sessions warm.</li>
<li>Config: render validation warnings with real line breaks instead of a literal <code>\n</code> sequence in CLI/audit output. Fixes #70140.</li>
<li>Cron/doctor: repair malformed persisted cron job IDs through <code>openclaw doctor</code>, including legacy <code>jobId</code>, non-string <code>id</code>, and missing <code>id</code> rows, so <code>cron list</code> no longer needs display-layer coercion for corrupt store data. Fixes #70128.</li>
<li>Discord: normalize prefixed channel targets only at the thread-binding API boundary, so <code>sessions_spawn({ runtime: "acp", thread: true })</code> can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.</li>
<li>Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when <code>name</code>, <code>parentId</code>, <code>parent</code>, or <code>ownerId</code> requires fetched raw data.</li>
<li>Discord: let <code>message</code> tool reactions resolve <code>user:<id></code> DM targets and preserve <code>channels.discord.guilds.<guild>.channels.<channel>.requireMention: false</code> during reply-stage activation fallback. Fixes #70165 and #69441.</li>
<li>Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.</li>
<li>ACP/Codex: run the bundled Codex ACP harness with an isolated <code>CODEX_HOME</code> and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88.</li>
<li>Gateway/client: keep long-running RPCs such as ACP <code>agent.wait</code> calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending.</li>
<li>Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james.</li>
<li>Telegram/polling: rebuild the polling HTTP transport after <code>getUpdates</code> 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys.</li>
<li>Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored <code>media://inbound/*</code> attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.</li>
<li>Slack/files: resolve <code>downloadFile</code> bot tokens from the runtime config when callers provide <code>cfg</code> without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.</li>
<li>Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.</li>
<li>Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.</li>
<li>CLI/Claude: keep stored Claude CLI sessions through OAuth refresh-token rotation by keying auth epochs on stable account identity instead of mutable OAuth token material. (#70452) Thanks @obviyus.</li>
<li>CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with <code>reason=transcript-missing</code> instead of silently starting fresh under <code>--resume</code>. Fixes #70177.</li>
<li>CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.</li>
<li>ACP/sessions_spawn: honor explicit <code>model</code> overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.</li>
<li>Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling <code>plugins.entries.diffs.config.security.allowRemoteViewer</code> closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>Diffs/tooling: re-read <code>viewerBaseUrl</code>, presentation defaults, and viewer access policy from live runtime config, and fail closed when the live <code>diffs</code> plugin entry disappears instead of reviving startup viewer settings. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: stop resurrecting removed live <code>memory-lancedb</code> hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: keep auto-recall and auto-capture hooks wired when those settings start disabled, so turning them on in live config starts recall and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Skill Workshop: keep the tool plus <code>before_prompt_build</code> / <code>agent_end</code> hooks wired while the plugin is disabled at startup, so turning the plugin back on in live config starts guidance and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Active Memory: stop reviving removed live <code>active-memory</code> config from startup snapshots, so removing the plugin entry turns the hook off immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>GitHub Copilot: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.github-copilot.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Ollama: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.ollama.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>OpenAI: re-read the plugin prompt-overlay personality from live runtime config, so GPT-5 system prompt contributions update without a restart when <code>plugins.entries.openai.config.personality</code> changes. Thanks @vincentkoc.</li>
<li>Amazon Bedrock: re-read live discovery and guardrail plugin config, so toggling <code>plugins.entries.amazon-bedrock.config.discovery</code> or <code>plugins.entries.amazon-bedrock.config.guardrail</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Codex: re-read the plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.codex.config.discovery</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Agents/subagents: drop bare <code>NO_REPLY</code> from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.</li>
<li>Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads.</li>
<li>CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash <code>openclaw status</code> or bootstrap secret scans.</li>
<li>Memory/LanceDB: retry initialization after a failed LanceDB load and report unsupported Intel macOS native runtime clearly instead of caching the failure or repeatedly attempting an install that cannot work.</li>
<li>CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.</li>
<li>Hooks/Slack: standardize shared message hook routing fields (<code>threadId</code> / <code>replyToId</code>) and stop Slack outbound delivery from re-running <code>message_sending</code> inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.</li>
<li>Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local <code>MEDIA:</code> attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.</li>
<li>Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed <code>gateway_start</code> hook, and move memory-core managed dreaming off the internal <code>gateway:startup</code> bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.</li>
<li>Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so <code>plugins.allow</code> remains enforced and <code>doctor</code>/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141.</li>
<li>Agents/openai-completions: enable malformed streamed tool-call argument repair for self-hosted OpenAI-compatible backends such as Kimi/SGLang, so fragmented tool-call arguments no longer reach tools as empty or unusable objects. Fixes #69672. (#70294) Thanks @MonkeyLeeT.</li>
<li>Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.</li>
<li>Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve <code>metadata-upgrade</code> pairing (platform / device family refresh) instead of being disconnected with <code>1008 pairing required</code>. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.</li>
<li>Gateway/pairing: treat any forwarded-header evidence (<code>Forwarded</code>, <code>X-Forwarded-*</code>, or <code>X-Real-IP</code>) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.</li>
<li>Agents/OpenAI: treat exact <code>NO_REPLY</code> assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.</li>
<li>Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware <code>final_answer</code> delivery, so split <code>MEDIA:<path></code> lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.</li>
<li>Anthropic/Claude Opus 4.7: normalize Opus 4.7 and <code>claude-cli</code> Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.</li>
<li>Gateway/pairing webchat: render <code>/pair qr</code> replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.</li>
<li>Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.</li>
<li>Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.</li>
<li>Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so <code>codex/gpt-5.x</code> sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.</li>
<li>Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy <code>OPENCLAW_CODEX_APP_SERVER_GUARDIAN</code> shortcut is removed; use plugin config <code>appServer.mode: "guardian"</code> or <code>OPENCLAW_CODEX_APP_SERVER_MODE=guardian</code>. Thanks @pashpashpash.</li>
<li>OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when <code>models.providers.openai.baseUrl</code> points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.</li>
<li>Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper <code>loadConfig()</code> calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.</li>
<li>Discord: pass resolved runtime config through guild and moderation action helpers, so thread-originated Discord commands can run channel, member, role, and guild actions without falling back to runtime config reads. (#70215) Thanks @szponeczek.</li>
<li>CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram <code>streaming</code> into <code>accounts.default</code>.</li>
<li>Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom <code>session.store</code> paths.</li>
<li>Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in <code>openclaw update --json</code>.</li>
<li>Ollama: forward OpenClaw thinking control to native <code>/api/chat</code> requests as top-level <code>think</code>, so <code>/think off</code> and <code>openclaw agent --thinking off</code> suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.</li>
<li>Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.</li>
<li>Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted <code>> Reasoning:</code> text, preventing <code>/reasoning on</code> from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.</li>
<li>Discord: read <code>channel.parentId</code> through a safe accessor in the slash-command, reaction, and model-picker paths so partial <code>GuildThreadChannel</code> prototype getters no longer throw <code>Cannot access rawData on partial Channel</code> when commands like <code>/new</code> run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.</li>
<li>Discord: use safe channel name and parent accessors across voice command authorization, so <code>/vc</code> commands from partial Discord thread channels no longer crash on Carbon rawData getters. (#70199) Thanks @hanamizuki.</li>
<li>Discord: make auto-thread parent transcript inheritance opt-in via <code>channels.discord.thread.inheritParent</code>, keeping newly created Discord thread sessions isolated by default while preserving explicit inheritance for configured accounts. Fixes #69907. (#69986) Thanks @Blahdude.</li>
<li>Browser/Chrome MCP: reset cached existing-session control sessions when a <code>navigate_page</code> call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.</li>
<li>Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.</li>
<li>Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.</li>
<li>Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.</li>
<li>OpenCode Go: canonicalize stale bundled <code>opencode-go</code> base URLs from <code>/go</code> or <code>/go/v1</code> to <code>/zen/go</code> or <code>/zen/go/v1</code>, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)</li>
<li>CLI/channels: honor <code>channels.<id>.enabled=false</code> as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.</li>
<li>Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.</li>
<li>Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long <code>/status</code> output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.</li>
<li>Gateway/session history: re-check current auth and <code>chat.history</code> scope before later SSE keepalives and transcript updates, so active session-history streams close before delivering post-revocation events.</li>
<li>Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.</li>
<li>CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.</li>
<li>Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so <code>openclaw doctor</code> and post-update doctor runs no longer crash with <code>Cannot read properties of undefined (reading 'clear')</code>. (#70135) Thanks @ngutman.</li>
<li>Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.</li>
<li>memory-core/dreaming: surface a <code>Dreaming status: blocked</code> line in <code>openclaw memory status</code> when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent <code>heartbeat</code> blocks excluding <code>main</code>, and <code>heartbeat.every</code> set to <code>0</code>/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.</li>
<li>Cron/run-log: report generic <code>message</code> tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.</li>
<li>Doctor/channels: merge configured-channel doctor hooks across read-only, loaded, setup, and runtime plugin discovery so partial adapters no longer hide runtime-only compatibility repair or allowlist warnings, preserve disabled-channel opt-outs, and ignore malformed hook values before they can mask valid fallbacks. (#69919) Thanks @gumadeiras.</li>
<li>Models/CLI: show bundled provider-owned static catalog rows in <code>models list --all</code> before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd.</li>
<li>Models/CLI: clarify that <code>models list --provider</code> expects provider ids and reject display labels before loading model discovery. (#70504) Thanks @shakkernerd.</li>
<li>Configure: skip generic CLI startup bootstrap for <code>openclaw configure</code> and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus.</li>
<li>Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects.</li>
<li>OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.</li>
<li>CLI sessions: keep provider-owned CLI sessions through implicit daily expiry while preserving explicit reset behavior, and retain Claude CLI binding metadata across gateway agent requests. (#70106) Thanks @obviyus.</li>
<li>fix(config): accept truncateAfterCompaction (#68395). Thanks @MonkeyLeeT</li>
<li>CLI/Claude: keep Claude CLI session bindings stable across OAuth access-token refreshes, so gateway restarts continue the same Claude conversation instead of minting a fresh one. (#70132) Thanks @obviyus.</li>
<li>QQBot: add <code>INTERACTION</code> intent (<code>1 << 26</code>) to the gateway constants and include it in the <code>FULL_INTENTS</code> mask so interaction events are received. (#70143) Thanks @cxyhhhhh.</li>
<li>Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc.</li>
<li>Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.</li>
<li>Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.</li>
<li>Security/dotenv: block workspace <code>.env</code> overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.</li>
<li>Telegram: require the same <code>/models</code> authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.</li>
<li>Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi <code>0.68.1</code> session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.</li>
<li>Agents/Pi: honor explicit <code>strict-agentic</code> execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.</li>
<li>OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.</li>
<li>Gateway/Control UI: require authenticated Control UI read access before serving <code>/__openclaw/control-ui-config.json</code> when <code>gateway.auth</code> is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.</li>
<li>Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus.</li>
<li>Build/npm publish: fail postpublish verification when root <code>dist/*</code> files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.22/OpenClaw-2026.4.22.zip" length="47883836" type="application/octet-stream" sparkle:edSignature="kzJ2j2sWX4H+ZIc4dXEFORYr9tk3w1txpjCJ38cdSFz6yWHU0M6Sx9zN0DB7JGIpv1QC+D+jFbWBkl4SJqW2AA=="/>
</item>
</channel>
</rss>

View File

@@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_style = space
indent_size = 2
max_line_length = off
ktlint_standard_filename = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_property-naming = disabled

View File

@@ -15,6 +15,7 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
- [x] Request camera/location and other permissions in onboarding/settings flow
- [x] Push notifications for gateway/chat status updates
- [x] Security hardening (biometric lock, token handling, safer defaults)
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening

View File

@@ -7,284 +7,274 @@ val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASS
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val resolvedAndroidStoreFile =
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
}
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
}
}
val hasAndroidReleaseSigning =
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
val wantsAndroidReleaseBuild =
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
}
plugins {
id("com.android.application")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "ai.openclaw.app"
compileSdk = 36
namespace = "ai.openclaw.app"
compileSdk = 36
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
}
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
}
}
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
}
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
}
}
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042600
versionName = "2026.4.26"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042700
versionName = "2026.4.27"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
flavorDimensions += "store"
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")
}
productFlavors {
create("play") {
dimension = "store"
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
create("thirdParty") {
dimension = "store"
}
}
buildFeatures {
compose = true
buildConfig = true
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
}
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
lint {
lintConfig = file("lint.xml")
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
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
}
}
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
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
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.03.01")
implementation(composeBom)
androidTestImplementation(composeBom)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.webkit:webkit:1.15.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.webkit)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation(libs.androidx.compose.ui.tooling)
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
// Material Components (XML theme + resources)
implementation(libs.material)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.commonmark:commonmark:0.28.0")
implementation("org.commonmark:commonmark-ext-autolink:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0")
implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0")
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
implementation(libs.bcprov)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.gfm.strikethrough)
implementation(libs.commonmark.ext.gfm.tables)
implementation(libs.commonmark.ext.task.list.items)
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
// CameraX (for node.invoke camera.* parity)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.video)
implementation(libs.play.services.code.scanner)
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation(libs.dnsjava)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3")
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.mockwebserver)
testImplementation(libs.robolectric)
testRuntimeOnly(libs.junit.vintage.engine)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
useJUnitPlatform()
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
}
}
}

13
apps/android/app/lint.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="AndroidGradlePluginVersion" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="IconLauncherShape" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
<issue id="HighAppVersionCode" severity="ignore" />
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
<issue id="OldTargetApi" severity="ignore" />
</lint>

View File

@@ -12,8 +12,6 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<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
@@ -21,7 +19,6 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
@@ -41,7 +38,7 @@
<application
android:name=".NodeApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@@ -8,9 +8,8 @@ object DeviceNames {
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}
.getOrNull()
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()
?.trim()
.orEmpty()

View File

@@ -1,6 +1,8 @@
package ai.openclaw.app
enum class LocationMode(val rawValue: String) {
enum class LocationMode(
val rawValue: String,
) {
Off("off"),
WhileUsing("whileUsing"),
;

View File

@@ -1,18 +1,18 @@
package ai.openclaw.app
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.view.WindowCompat
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import ai.openclaw.app.ui.RootScreen
import ai.openclaw.app.ui.OpenClawTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {

View File

@@ -1,9 +1,5 @@
package ai.openclaw.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
@@ -13,6 +9,10 @@ import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.voice.VoiceConversationEntry
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,7 +22,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(app: Application) : AndroidViewModel(app) {
class MainViewModel(
app: Application,
) : AndroidViewModel(app) {
private val nodeApp = app as NodeApp
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
@@ -143,7 +145,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val sms: SmsManager
get() = ensureRuntime().sms
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
fun attachRuntimeUi(
owner: LifecycleOwner,
permissionRequester: PermissionRequester,
) {
val runtime = runtimeRef.value ?: return
runtime.camera.attachLifecycleOwner(owner)
runtime.camera.attachPermissionRequester(permissionRequester)
@@ -245,9 +250,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
enabled: Boolean,
start: String,
end: String,
): Boolean {
return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
): Boolean = ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
@@ -340,9 +343,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return ensureRuntime().isTrustedCanvasActionUrl(rawUrl)
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = ensureRuntime().isTrustedCanvasActionUrl(rawUrl)
fun requestCanvasRehydrate(source: String = "screen_tab") {
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
@@ -376,7 +377,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().abortChat()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
fun sendChat(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
) {
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
}
@@ -384,11 +389,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean {
return ensureRuntime().sendChatAwaitAcceptance(
): Boolean =
ensureRuntime().sendChatAwaitAcceptance(
message = message,
thinking = thinking,
attachments = attachments,
)
}
}

View File

@@ -21,13 +21,15 @@ class NodeApp : Application() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
StrictMode.ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
StrictMode.VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),

View File

@@ -140,10 +140,14 @@ class NodeForegroundService : Service() {
mgr.createNotificationChannel(channel)
}
private fun buildNotification(title: String, text: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
private fun buildNotification(
title: String,
text: String,
): Notification {
val launchIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
@@ -161,7 +165,8 @@ class NodeForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
@@ -233,8 +238,8 @@ internal fun voiceNotificationSuffix(
manualMicListening: Boolean,
talkListening: Boolean,
talkSpeaking: Boolean,
): String {
return when (mode) {
): String =
when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> " · Talk: Speaking"
@@ -249,11 +254,11 @@ internal fun voiceNotificationSuffix(
}
VoiceCaptureMode.Off -> ""
}
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode {
return VoiceCaptureMode.entries.firstOrNull { it.name == this } ?: VoiceCaptureMode.Off
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
VoiceCaptureMode.entries.firstOrNull {
it.name == this
} ?: VoiceCaptureMode.Off
private data class VoiceNotificationBase(
val status: String,

View File

@@ -1,11 +1,5 @@
package ai.openclaw.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import ai.openclaw.app.chat.ChatController
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -19,11 +13,43 @@ import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.*
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
import ai.openclaw.app.node.CallLogHandler
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CameraHandler
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.ConnectionManager
import ai.openclaw.app.node.ContactsHandler
import ai.openclaw.app.node.DEFAULT_SEAM_COLOR_ARGB
import ai.openclaw.app.node.DebugHandler
import ai.openclaw.app.node.DeviceHandler
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.node.InvokeDispatcher
import ai.openclaw.app.node.LocationCaptureManager
import ai.openclaw.app.node.LocationHandler
import ai.openclaw.app.node.MotionHandler
import ai.openclaw.app.node.NodePresenceAliveBeacon
import ai.openclaw.app.node.NotificationsHandler
import ai.openclaw.app.node.PhotosHandler
import ai.openclaw.app.node.Quad
import ai.openclaw.app.node.SmsHandler
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.node.SystemHandler
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import ai.openclaw.app.node.invokeErrorFromThrowable
import ai.openclaw.app.node.parseHexColorArgb
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.app.voice.MicCaptureManager
import ai.openclaw.app.voice.TalkModeManager
import ai.openclaw.app.voice.VoiceConversationEntry
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -75,122 +101,137 @@ class NodeRuntime(
private var connectedEndpoint: GatewayEndpoint? = null
private var activeGatewayAuth: GatewayConnectAuth? = null
private val cameraHandler: CameraHandler = CameraHandler(
appContext = appContext,
camera = camera,
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val cameraHandler: CameraHandler =
CameraHandler(
appContext = appContext,
camera = camera,
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val debugHandler: DebugHandler = DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
private val debugHandler: DebugHandler =
DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val locationHandler: LocationHandler =
LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
)
private val deviceHandler: DeviceHandler =
DeviceHandler(
appContext = appContext,
smsEnabled = SensitiveFeatureConfig.smsEnabled,
callLogEnabled = SensitiveFeatureConfig.callLogEnabled,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
private val notificationsHandler: NotificationsHandler =
NotificationsHandler(
appContext = appContext,
)
private val systemHandler: SystemHandler = SystemHandler(
appContext = appContext,
)
private val systemHandler: SystemHandler =
SystemHandler(
appContext = appContext,
)
private val photosHandler: PhotosHandler = PhotosHandler(
appContext = appContext,
)
private val photosHandler: PhotosHandler =
PhotosHandler(
appContext = appContext,
)
private val contactsHandler: ContactsHandler = ContactsHandler(
appContext = appContext,
)
private val contactsHandler: ContactsHandler =
ContactsHandler(
appContext = appContext,
)
private val calendarHandler: CalendarHandler = CalendarHandler(
appContext = appContext,
)
private val calendarHandler: CalendarHandler =
CalendarHandler(
appContext = appContext,
)
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val callLogHandler: CallLogHandler =
CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler =
MotionHandler(
appContext = appContext,
)
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
private val smsHandlerImpl: SmsHandler =
SmsHandler(
sms = sms,
)
private val a2uiHandler: A2UIHandler = A2UIHandler(
canvas = canvas,
json = json,
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
)
private val a2uiHandler: A2UIHandler =
A2UIHandler(
canvas = canvas,
json = json,
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
)
private val connectionManager: ConnectionManager = ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
private val connectionManager: ConnectionManager =
ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
smsSearchPossible = { SensitiveFeatureConfig.smsEnabled && sms.hasTelephonyFeature() },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher(
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
systemHandler = systemHandler,
photosHandler = photosHandler,
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
private val invokeDispatcher: InvokeDispatcher =
InvokeDispatcher(
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
systemHandler = systemHandler,
photosHandler = photosHandler,
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
smsFeatureEnabled = { SensitiveFeatureConfig.smsEnabled },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
@@ -247,6 +288,8 @@ class NodeRuntime(
private var gatewayAgents: List<GatewayAgentSummary> = emptyList()
private var didAutoRequestCanvasRehydrate = false
private val canvasRehydrateSeq = AtomicLong(0)
@Volatile private var nodePresenceAliveLastSuccessAtMs: Long? = null
private var operatorConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
@@ -302,6 +345,7 @@ class NodeRuntime(
_canvasRehydrateErrorText.value = null
updateStatus()
showLocalCanvasOnConnect()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
if (endpoint != null && auth != null) {
@@ -344,21 +388,22 @@ class NodeRuntime(
).also {
it.applyMainSessionKey(_mainSessionKey.value)
}
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
).also { speaker ->
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> =
lazy {
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
).also { speaker ->
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
}
}
}
private val voiceReplySpeaker: TalkModeManager
get() = voiceReplySpeakerLazy.value
@@ -504,7 +549,10 @@ class NodeRuntime(
}
}
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
fun requestCanvasRehydrate(
source: String = "manual",
force: Boolean = true,
) {
scope.launch {
if (!_nodeConnected.value) {
_canvasRehydratePending.value = false
@@ -567,16 +615,22 @@ class NodeRuntime(
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun resetGatewaySetupAuth() {
prefs.clearGatewaySetupAuth()
val deviceId = identityStore.loadOrCreate().deviceId
deviceAuthStore.clearToken(deviceId, "node")
deviceAuthStore.clearToken(deviceId, "operator")
}
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
@@ -649,6 +703,60 @@ class NodeRuntime(
reconnectPreferredGatewayOnForeground()
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
}
}
private fun publishNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean = false,
) {
scope.launch {
sendNodePresenceAliveBeacon(trigger = trigger, throttleRecentSuccess = throttleRecentSuccess)
}
}
private suspend fun sendNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean,
) {
if (!_nodeConnected.value) return
val nowMs = System.currentTimeMillis()
if (
throttleRecentSuccess &&
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = nowMs,
lastSuccessAtMs = nodePresenceAliveLastSuccessAtMs,
)
) {
return
}
val client = connectionManager.buildClientInfo(clientId = "openclaw-android", clientMode = "node")
val payloadJson =
NodePresenceAliveBeacon.makePayloadJson(
trigger = trigger,
sentAtMs = nowMs,
displayName = client.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: "Android",
version = client.version,
platform = NodePresenceAliveBeacon.androidPlatformLabel(),
deviceFamily = client.deviceFamily,
modelIdentifier = client.modelIdentifier,
)
val result =
nodeSession.sendNodeEventDetailed(
event = NodePresenceAliveBeacon.EVENT_NAME,
payloadJson = payloadJson,
)
if (!result.ok) return
val response = NodePresenceAliveBeacon.decodeResponse(result.payloadJson)
if (response?.handled == true) {
nodePresenceAliveLastSuccessAtMs = nowMs
} else {
Log.d(
"OpenClawNode",
"node.presence.alive not handled: ${NodePresenceAliveBeacon.sanitizeReasonForLog(response?.reason)}",
)
}
}
@@ -748,9 +856,7 @@ class NodeRuntime(
enabled: Boolean,
start: String,
end: String,
): Boolean {
return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
): Boolean = prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
prefs.setNotificationForwardingMaxEventsPerMinute(value)
@@ -927,10 +1033,11 @@ class NodeRuntime(
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
val fp = tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
val fp =
tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
_pendingGatewayTrust.value =
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
}
@@ -955,14 +1062,13 @@ class NodeRuntime(
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
}
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth {
return explicitAuth
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth =
explicitAuth
?: GatewayConnectAuth(
token = prefs.loadGatewayToken(),
bootstrapToken = prefs.loadGatewayBootstrapToken(),
password = prefs.loadGatewayPassword(),
)
}
fun acceptGatewayTrustPrompt() {
val prompt = _pendingGatewayTrust.value ?: return
@@ -976,21 +1082,19 @@ class NodeRuntime(
_statusText.value = "Offline"
}
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
return when (failure) {
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String =
when (failure) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
"Failed: couldn't reach the secure gateway endpoint for this host."
}
}
private fun hasRecordAudioPermission(): Boolean {
return (
private fun hasRecordAudioPermission(): Boolean =
(
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
)
fun connectManual() {
val host = manualHost.value.trim()
@@ -1053,15 +1157,26 @@ class NodeRuntime(
}
val userActionObj = (root["userAction"] as? JsonObject) ?: root
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val actionId =
(userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID
.randomUUID()
.toString()
}
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
(userActionObj["surfaceId"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
.ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
(userActionObj["sourceComponentId"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
.ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey()
@@ -1112,9 +1227,7 @@ class NodeRuntime(
}
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return a2uiHandler.isTrustedCanvasActionUrl(rawUrl)
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = a2uiHandler.isTrustedCanvasActionUrl(rawUrl)
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
@@ -1141,7 +1254,11 @@ class NodeRuntime(
chat.abort()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
fun sendChat(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
}
@@ -1149,11 +1266,12 @@ class NodeRuntime(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean {
return chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
}
): Boolean = chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
private fun handleGatewayEvent(event: String, payloadJson: String?) {
private fun handleGatewayEvent(
event: String,
payloadJson: String?,
) {
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
@@ -1199,7 +1317,12 @@ class NodeRuntime(
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return@mapNotNull null
val name = obj["name"].asStringOrNull()?.trim()
val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim()
val emoji =
obj["identity"]
.asObjectOrNull()
?.get("emoji")
.asStringOrNull()
?.trim()
GatewayAgentSummary(
id = id,
name = name?.takeIf { it.isNotEmpty() },
@@ -1356,7 +1479,11 @@ class NodeRuntime(
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
}
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
private fun showCameraHud(
message: String,
kind: CameraHudKind,
autoHideMs: Long? = null,
) {
val token = cameraHudSeq.incrementAndGet()
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
@@ -1367,7 +1494,6 @@ class NodeRuntime(
}
}
}
}
internal fun resolveOperatorSessionConnectAuth(
@@ -1416,9 +1542,7 @@ internal fun resolveOperatorSessionConnectAuth(
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean {
return resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
}
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,

View File

@@ -3,15 +3,15 @@ package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
enum class NotificationPackageFilterMode(val rawValue: String) {
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
Allowlist("allowlist"),
Blocklist("blocklist"),
;
companion object {
fun fromRawValue(raw: String?): NotificationPackageFilterMode {
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
@@ -50,7 +50,8 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours(
return true
}
val now =
Instant.ofEpochMilli(nowEpochMs)
Instant
.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
@@ -82,7 +83,10 @@ internal class NotificationBurstLimiter {
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}

View File

@@ -1,30 +1,32 @@
package ai.openclaw.app
import android.content.pm.PackageManager
import android.content.Intent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
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
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
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) {
class PermissionRequester(
private val activity: ComponentActivity,
) {
private val mutex = Mutex()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
@@ -74,10 +76,10 @@ class PermissionRequester(private val activity: ComponentActivity) {
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
@@ -104,6 +106,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: Boolean?) {
if (!finished.compareAndSet(false, true)) return
removeObserver()
@@ -125,7 +128,8 @@ class PermissionRequester(private val activity: ComponentActivity) {
}
}
dialog =
AlertDialog.Builder(activity)
AlertDialog
.Builder(activity)
.setTitle("Permission required")
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton("Continue") { _, _ -> finish(true) }
@@ -154,7 +158,8 @@ class PermissionRequester(private val activity: ComponentActivity) {
observer = actualObserver
lifecycle.addObserver(actualObserver)
dialog =
AlertDialog.Builder(activity)
AlertDialog
.Builder(activity)
.setTitle("Enable permission in Settings")
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
@@ -165,8 +170,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
}
.setNegativeButton("Cancel", null)
}.setNegativeButton("Cancel", null)
.setOnDismissListener { removeObserver() }
.show()
}

View File

@@ -46,7 +46,8 @@ class SecurePrefs(
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val masterKey by lazy {
MasterKey.Builder(appContext)
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
@@ -420,16 +421,20 @@ class SecurePrefs(
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
) {
val key = "gateway.tls.$stableId"
plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return securePrefs.getString(key, null)
}
fun getString(key: String): String? = securePrefs.getString(key, null)
fun putString(key: String, value: String) {
fun putString(
key: String,
value: String,
) {
securePrefs.edit { putString(key, value) }
}
@@ -437,15 +442,17 @@ class SecurePrefs(
securePrefs.edit { remove(key) }
}
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
private fun createSecurePrefs(
context: Context,
name: String,
): SharedPreferences =
EncryptedSharedPreferences.create(
context,
name,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
@@ -504,8 +511,7 @@ class SecurePrefs(
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}
.toSet()
}.toSet()
} catch (_: Throwable) {
emptySet()
}

View File

@@ -15,10 +15,17 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
return trimmed.removePrefix("agent:").substringBefore(':').trim().ifEmpty { null }
return trimmed
.removePrefix("agent:")
.substringBefore(':')
.trim()
.ifEmpty { null }
}
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,
): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}

View File

@@ -1,14 +1,14 @@
package ai.openclaw.app
enum class VoiceWakeMode(val rawValue: String) {
enum class VoiceWakeMode(
val rawValue: String,
) {
Off("off"),
Foreground("foreground"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): VoiceWakeMode {
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
}
fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
}
}

View File

@@ -4,18 +4,26 @@ object WakeWords {
const val maxWords: Int = 32
const val maxWordLength: Int = 64
fun parseCommaSeparated(input: String): List<String> {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun parseCommaSeparated(input: String): List<String> = input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
fun parseIfChanged(input: String, current: List<String>): List<String>? {
fun parseIfChanged(
input: String,
current: List<String>,
): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
fun sanitize(
words: List<String>,
defaults: List<String>,
): List<String> {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
words
.map { it.trim() }
.filter { it.isNotEmpty() }
.take(maxWords)
.map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}

View File

@@ -1,8 +1,6 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -17,6 +15,8 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class ChatController(
private val scope: CoroutineScope,
@@ -173,12 +173,12 @@ class ChatController(
}
_messages.value =
_messages.value +
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
armPendingRunTimeout(runId)
synchronized(pendingRuns) {
@@ -255,7 +255,10 @@ class ChatController(
}
}
fun handleGatewayEvent(event: String, payloadJson: String?) {
fun handleGatewayEvent(
event: String,
payloadJson: String?,
) {
when (event) {
"tick" -> {
scope.launch { pollHealthIfNeeded(force = false) }
@@ -279,7 +282,10 @@ class ChatController(
}
}
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
private suspend fun bootstrap(
forceHealth: Boolean,
refreshSessions: Boolean,
) {
_errorText.value = null
_healthOk.value = false
clearPendingRuns()
@@ -298,7 +304,10 @@ class ChatController(
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
pollHealthIfNeeded(force = forceHealth)
if (refreshSessions) {
@@ -371,7 +380,10 @@ class ChatController(
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
@@ -542,22 +554,24 @@ class ChatController(
}
}
private fun parseRunId(resJson: String): String? {
return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun normalizeThinking(raw: String): String {
return when (raw.trim().lowercase()) {
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
}
}
internal data class MainSessionState(
@@ -582,7 +596,10 @@ internal fun applyMainSessionKey(
)
}
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
internal fun reconcileMessageIds(
previous: List<ChatMessage>,
incoming: List<ChatMessage>,
): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
@@ -613,9 +630,15 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
listOf(
part.type.trim().lowercase(),
part.text?.trim().orEmpty(),
part.mimeType?.trim()?.lowercase().orEmpty(),
part.mimeType
?.trim()
?.lowercase()
.orEmpty(),
part.fileName?.trim().orEmpty(),
part.base64?.hashCode()?.toString().orEmpty(),
part.base64
?.hashCode()
?.toString()
.orEmpty(),
).joinToString(separator = "\u001F")
}

View File

@@ -19,21 +19,44 @@ private data class PersistedDeviceAuthMetadata(
)
interface DeviceAuthTokenStore {
fun loadEntry(deviceId: String, role: String): DeviceAuthEntry?
fun loadToken(deviceId: String, role: String): String? = loadEntry(deviceId, role)?.token
fun saveToken(deviceId: String, role: String, token: String, scopes: List<String> = emptyList())
fun clearToken(deviceId: String, role: String)
fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry?
fun loadToken(
deviceId: String,
role: String,
): String? = loadEntry(deviceId, role)?.token
fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String> = emptyList(),
)
fun clearToken(
deviceId: String,
role: String,
)
}
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
class DeviceAuthStore(
private val prefs: SecurePrefs,
) : DeviceAuthTokenStore {
private val json = Json { ignoreUnknownKeys = true }
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? {
override fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry? {
val key = tokenKey(deviceId, role)
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val normalizedRole = normalizeRole(role)
val metadata =
prefs.getString(metadataKey(deviceId, role))
prefs
.getString(metadataKey(deviceId, role))
?.let { raw ->
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
}
@@ -45,7 +68,12 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
)
}
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) {
override fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String>,
) {
val normalizedScopes = normalizeScopes(scopes)
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
@@ -60,19 +88,28 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
)
}
override fun clearToken(deviceId: String, role: String) {
override fun clearToken(
deviceId: String,
role: String,
) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
prefs.remove(metadataKey(deviceId, role))
}
private fun tokenKey(deviceId: String, role: String): String {
private fun tokenKey(
deviceId: String,
role: String,
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
private fun metadataKey(deviceId: String, role: String): String {
private fun metadataKey(
deviceId: String,
role: String,
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
@@ -82,11 +119,10 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
private fun normalizeRole(role: String): String = role.trim().lowercase()
private fun normalizeScopes(scopes: List<String>): List<String> {
return scopes
private fun normalizeScopes(scopes: List<String>): List<String> =
scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.sorted()
}
}

View File

@@ -2,10 +2,10 @@ package ai.openclaw.app.gateway
import android.content.Context
import android.util.Base64
import java.io.File
import java.security.MessageDigest
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
@Serializable
data class DeviceIdentity(
@@ -15,9 +15,12 @@ data class DeviceIdentity(
val createdAtMs: Long,
)
class DeviceIdentityStore(context: Context) {
class DeviceIdentityStore(
context: Context,
) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
@@ -41,15 +44,27 @@ class DeviceIdentityStore(context: Context) {
return fresh
}
fun signPayload(payload: String, identity: DeviceIdentity): String? {
return try {
fun signPayload(
payload: String,
identity: DeviceIdentity,
): String? =
try {
// Use BC lightweight API directly — JCA provider registration is broken by R8
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes)
val pkInfo =
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
.getInstance(privateKeyBytes)
val parsed = pkInfo.parsePrivateKey()
val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets
val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0)
val signer = org.bouncycastle.crypto.signers.Ed25519Signer()
val rawPrivate =
org.bouncycastle.asn1.DEROctetString
.getInstance(parsed)
.octets
val privateKey =
org.bouncycastle.crypto.params
.Ed25519PrivateKeyParameters(rawPrivate, 0)
val signer =
org.bouncycastle.crypto.signers
.Ed25519Signer()
signer.init(true, privateKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
signer.update(payloadBytes, 0, payloadBytes.size)
@@ -58,14 +73,21 @@ class DeviceIdentityStore(context: Context) {
android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
null
}
}
fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean {
return try {
fun verifySelfSignature(
payload: String,
signatureBase64Url: String,
identity: DeviceIdentity,
): Boolean =
try {
val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0)
val pubKey =
org.bouncycastle.crypto.params
.Ed25519PublicKeyParameters(rawPublicKey, 0)
val sigBytes = base64UrlDecode(signatureBase64Url)
val verifier = org.bouncycastle.crypto.signers.Ed25519Signer()
val verifier =
org.bouncycastle.crypto.signers
.Ed25519Signer()
verifier.init(false, pubKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
verifier.update(payloadBytes, 0, payloadBytes.size)
@@ -74,7 +96,6 @@ class DeviceIdentityStore(context: Context) {
android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e)
false
}
}
private fun base64UrlDecode(input: String): ByteArray {
val normalized = input.replace('-', '+').replace('_', '/')
@@ -82,18 +103,15 @@ class DeviceIdentityStore(context: Context) {
return Base64.decode(padded, Base64.DEFAULT)
}
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
return try {
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
base64UrlEncode(raw)
} catch (_: Throwable) {
null
}
}
private fun load(): DeviceIdentity? {
return readIdentity(identityFile)
}
private fun load(): DeviceIdentity? = readIdentity(identityFile)
private fun readIdentity(file: File): DeviceIdentity? {
return try {
@@ -125,15 +143,22 @@ class DeviceIdentityStore(context: Context) {
private fun generate(): DeviceIdentity {
// Use BC lightweight API directly to avoid JCA provider issues with R8
val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator()
kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom()))
val kpGen =
org.bouncycastle.crypto.generators
.Ed25519KeyPairGenerator()
kpGen.init(
org.bouncycastle.crypto.params
.Ed25519KeyGenerationParameters(java.security.SecureRandom()),
)
val kp = kpGen.generateKeyPair()
val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
val rawPublic = pubKey.encoded // 32 bytes
val rawPublic = pubKey.encoded // 32 bytes
val deviceId = sha256Hex(rawPublic)
// Encode private key as PKCS8 for storage
val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey)
val privKeyInfo =
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
.createPrivateKeyInfo(privKey)
val pkcs8Bytes = privKeyInfo.encoded
return DeviceIdentity(
deviceId = deviceId,
@@ -143,14 +168,13 @@ class DeviceIdentityStore(context: Context) {
)
}
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
return try {
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
sha256Hex(raw)
} catch (_: Throwable) {
null
}
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
@@ -164,9 +188,11 @@ class DeviceIdentityStore(context: Context) {
return String(out)
}
private fun base64UrlEncode(data: ByteArray): String {
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(
data,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
companion object {
private val HEX = "0123456789abcdef".toCharArray()

View File

@@ -1,21 +1,17 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import java.io.IOException
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,19 +28,27 @@ import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Message
import org.xbill.DNS.Name
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.Record
import org.xbill.DNS.Rcode
import org.xbill.DNS.Record
import org.xbill.DNS.Resolver
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TextParseException
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.TextParseException
import org.xbill.DNS.Type
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -66,15 +70,38 @@ class GatewayDiscovery(
private var unicastJob: Job? = null
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
private val availableNetworks = ConcurrentHashMap.newKeySet<Network>()
private val serviceInfoCallbacks = ConcurrentHashMap<String, Any>()
@Volatile private var lastWideAreaRcode: Int? = null
@Volatile private var lastWideAreaCount: Int = 0
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
availableNetworks.add(network)
}
override fun onLost(network: Network) {
availableNetworks.remove(network)
}
}
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStartDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onStopDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
@@ -86,17 +113,29 @@ class GatewayDiscovery(
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
localById.remove(id)
unregisterServiceInfoCallback(id)
publish()
}
}
init {
startNetworkTracking()
startLocalDiscovery()
if (!wideAreaDomain.isNullOrBlank()) {
startUnicastDiscovery(wideAreaDomain)
}
}
private fun startNetworkTracking() {
val cm = connectivity ?: return
cm.activeNetwork?.let(availableNetworks::add)
try {
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startLocalDiscovery() {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
@@ -128,43 +167,124 @@ class GatewayDiscovery(
}
private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService(
serviceInfo,
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
resolveWithServiceInfoCallback(serviceInfo)
} else {
resolveLegacy(serviceInfo)
}
}
override fun onServiceResolved(resolved: NsdServiceInfo) {
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
if (serviceInfoCallbacks.containsKey(id)) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
val callback =
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceInfoCallbackUnregistered() {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceLost() {
localById.remove(id)
publish()
}
},
)
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
upsertResolvedService(serviceInfo)
}
}
serviceInfoCallbacks[id] = callback
try {
nsd.registerServiceInfoCallback(serviceInfo, dnsExecutor, callback)
} catch (_: Throwable) {
serviceInfoCallbacks.remove(id, callback)
}
}
private fun unregisterServiceInfoCallback(id: String) {
val callback = serviceInfoCallbacks.remove(id) ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
try {
nsd.unregisterServiceInfoCallback(callback as NsdManager.ServiceInfoCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun resolveLegacy(serviceInfo: NsdServiceInfo) {
val listener =
object : NsdManager.ResolveListener {
override fun onResolveFailed(
serviceInfo: NsdServiceInfo,
errorCode: Int,
) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
upsertResolvedService(resolved)
}
}
try {
NsdManager::class.java
.getMethod("resolveService", NsdServiceInfo::class.java, NsdManager.ResolveListener::class.java)
.invoke(nsd, serviceInfo, listener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun upsertResolvedService(resolved: NsdServiceInfo) {
val host = resolvedHostAddress(resolved) ?: return
val port = resolved.port
if (port <= 0) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
publish()
}
private fun resolvedHostAddress(resolved: NsdServiceInfo): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return resolved.hostAddresses.firstOrNull()?.hostAddress
}
return legacyHostAddress(resolved)
}
private fun legacyHostAddress(resolved: NsdServiceInfo): String? {
return try {
val host = NsdServiceInfo::class.java.getMethod("getHost").invoke(resolved) as? InetAddress
host?.hostAddress
} catch (_: Throwable) {
null
}
}
private fun publish() {
@@ -193,15 +313,17 @@ class GatewayDiscovery(
}
}
private fun stableId(serviceName: String, domain: String): String {
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
}
private fun stableId(
serviceName: String,
domain: String,
): String = "$serviceType|$domain|${normalizeName(serviceName)}"
private fun normalizeName(raw: String): String {
return raw.trim().split(Regex("\\s+")).joinToString(" ")
}
private fun normalizeName(raw: String): String = raw.trim().split(Regex("\\s+")).joinToString(" ")
private fun txt(info: NsdServiceInfo, key: String): String? {
private fun txt(
info: NsdServiceInfo,
key: String,
): String? {
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
@@ -210,17 +332,21 @@ class GatewayDiscovery(
}
}
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
return txt(info, key)?.toIntOrNull()
}
private fun txtInt(
info: NsdServiceInfo,
key: String,
): Int? = txt(info, key)?.toIntOrNull()
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
private fun txtBool(
info: NsdServiceInfo,
key: String,
): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}"
val ptrName = "${serviceType}$domain"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
@@ -293,8 +419,11 @@ class GatewayDiscovery(
}
}
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
val suffix = "${serviceType}${domain}"
private fun decodeInstanceName(
instanceFqdn: String,
domain: String,
): String {
val suffix = "${serviceType}$domain"
val withoutSuffix =
if (instanceFqdn.endsWith(suffix)) {
instanceFqdn.removeSuffix(suffix)
@@ -304,11 +433,12 @@ class GatewayDiscovery(
return normalizeName(stripTrailingDot(withoutSuffix))
}
private fun stripTrailingDot(raw: String): String {
return raw.removeSuffix(".")
}
private fun stripTrailingDot(raw: String): String = raw.removeSuffix(".")
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
private suspend fun lookupUnicastMessage(
name: String,
type: Int,
): Message? {
val query =
try {
Message.newQuery(
@@ -350,15 +480,17 @@ class GatewayDiscovery(
}
}
private fun records(msg: Message?, section: Int): List<Record> {
return msg?.getSection(section).orEmpty()
}
private fun records(
msg: Message?,
section: Int,
): List<Record> = msg?.getSection(section).orEmpty()
private fun keyName(raw: String): String {
return raw.trim().lowercase()
}
private fun keyName(raw: String): String = raw.trim().lowercase()
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
private fun recordsByName(
msg: Message,
section: Int,
): Map<String, List<Record>> {
val next = LinkedHashMap<String, MutableList<Record>>()
for (r in records(msg, section)) {
val name = r.name?.toString() ?: continue
@@ -367,7 +499,11 @@ class GatewayDiscovery(
return next
}
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
private fun recordByName(
msg: Message,
fqdn: String,
type: Int,
): Record? {
val key = keyName(fqdn)
val byNameAnswer = recordsByName(msg, Section.ANSWER)
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
@@ -377,7 +513,10 @@ class GatewayDiscovery(
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
}
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
private fun resolveHostFromMessage(
msg: Message?,
hostname: String,
): String? {
val m = msg ?: return null
val key = keyName(hostname)
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
@@ -390,7 +529,7 @@ class GatewayDiscovery(
val cm = connectivity ?: return null
// Prefer VPN (Tailscale) when present; otherwise use the active network.
cm.allNetworks.firstOrNull { n ->
trackedNetworks(cm).firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}?.let { return it }
@@ -398,12 +537,19 @@ class GatewayDiscovery(
return cm.activeNetwork
}
private fun trackedNetworks(cm: ConnectivityManager): List<Network> {
return buildList {
cm.activeNetwork?.let(::add)
addAll(availableNetworks)
}.distinct()
}
private fun createDirectResolver(): Resolver? {
val cm = connectivity ?: return null
val candidateNetworks =
buildList {
cm.allNetworks
trackedNetworks(cm)
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
@@ -416,8 +562,7 @@ class GatewayDiscovery(
.asSequence()
.flatMap { n ->
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
}
.distinctBy { it.hostAddress ?: it.toString() }
}.distinctBy { it.hostAddress ?: it.toString() }
.toList()
if (servers.isEmpty()) return null
@@ -440,7 +585,10 @@ class GatewayDiscovery(
}
}
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
private suspend fun rawQuery(
network: android.net.Network?,
wireQuery: ByteArray,
): ByteArray =
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
@@ -452,7 +600,10 @@ class GatewayDiscovery(
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(answer: ByteArray, rcode: Int) {
override fun onAnswer(
answer: ByteArray,
rcode: Int,
) {
cont.resume(answer)
}
@@ -463,7 +614,10 @@ class GatewayDiscovery(
)
}
private fun txtValue(records: List<TXTRecord>, key: String): String? {
private fun txtValue(
records: List<TXTRecord>,
key: String,
): String? {
val prefix = "$key="
for (r in records) {
val strings: List<String> =
@@ -482,11 +636,15 @@ class GatewayDiscovery(
return null
}
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
return txtValue(records, key)?.toIntOrNull()
}
private fun txtIntValue(
records: List<TXTRecord>,
key: String,
): Int? = txtValue(records, key)?.toIntOrNull()
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
private fun txtBoolValue(
records: List<TXTRecord>,
key: String,
): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}

View File

@@ -13,7 +13,10 @@ data class GatewayEndpoint(
val tlsFingerprintSha256: String? = null,
) {
companion object {
fun manual(host: String, port: Int): GatewayEndpoint =
fun manual(
host: String,
port: Int,
): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
name = "$host:$port",

View File

@@ -1,10 +1,6 @@
package ai.openclaw.app.gateway
import android.util.Log
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -30,6 +26,10 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
data class GatewayClientInfo(
val id: String,
@@ -77,8 +77,9 @@ private data class SelectedConnectAuth(
val attemptedDeviceTokenRetry: Boolean,
)
private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) :
IllegalStateException(gatewayError.message)
private class GatewayConnectFailure(
val gatewayError: GatewaySession.ErrorShape,
) : IllegalStateException(gatewayError.message)
class GatewaySession(
private val scope: CoroutineScope,
@@ -103,11 +104,18 @@ class GatewaySession(
val timeoutMs: Long?,
)
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
data class InvokeResult(
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
fun error(
code: String,
message: String,
) = InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
@@ -117,13 +125,18 @@ class GatewaySession(
val details: GatewayConnectErrorDetails? = null,
)
data class RpcResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
data class RpcResult(
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection(
@@ -137,9 +150,13 @@ class GatewaySession(
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@Volatile private var pendingDeviceTokenRetry = false
@Volatile private var deviceTokenRetryBudgetUsed = false
@Volatile private var reconnectPausedForAuthFailure = false
fun connect(
@@ -180,32 +197,78 @@ class GatewaySession(
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
suspend fun sendNodeEvent(
event: String,
payloadJson: String?,
): Boolean {
val conn = currentConnection ?: return false
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
return true
return try {
conn.request(
"node.event",
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
return false
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
false
}
}
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
suspend fun sendNodeEventDetailed(
event: String,
payloadJson: String?,
timeoutMs: Long = 8_000,
): RpcResult {
val conn =
currentConnection
?: return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "not connected"),
)
val params = buildNodeEventParams(event = event, payloadJson = payloadJson)
try {
val res = conn.request("node.event", params, timeoutMs = timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "node.event failed"),
)
}
}
private fun buildNodeEventParams(
event: String,
payloadJson: String?,
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
suspend fun request(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): String {
val res = requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
suspend fun requestDetailed(method: String, paramsJson: String?, timeoutMs: Long = 15_000): RpcResult {
suspend fun requestDetailed(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): RpcResult {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
@@ -239,7 +302,12 @@ class GatewaySession(
return false
}
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
val refreshedCapability =
payloadObj
?.get("canvasCapability")
.asStringOrNull()
?.trim()
.orEmpty()
if (refreshedCapability.isEmpty()) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
return false
@@ -258,7 +326,12 @@ class GatewaySession(
return true
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private data class RpcResponse(
val id: String,
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
)
private inner class Connection(
private val endpoint: GatewayEndpoint,
@@ -289,7 +362,11 @@ class GatewaySession(
}
}
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
suspend fun request(
method: String,
params: JsonElement?,
timeoutMs: Long,
): RpcResponse {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
@@ -327,13 +404,16 @@ class GatewaySession(
}
private fun buildClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
val builder =
OkHttpClient
.Builder()
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
val tlsConfig =
buildGatewayTlsConfig(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
if (tlsConfig != null) {
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
@@ -342,7 +422,10 @@ class GatewaySession(
}
private inner class Listener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
scope.launch {
try {
val nonce = awaitConnectNonce()
@@ -354,11 +437,18 @@ class GatewaySession(
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
scope.launch { handleMessage(text) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(t)
}
@@ -369,7 +459,11 @@ class GatewaySession(
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
}
@@ -420,7 +514,7 @@ class GatewaySession(
deviceTokenRetryBudgetUsed = true
} else if (
selectedAuth.attemptedDeviceTokenRetry &&
shouldClearStoredDeviceTokenAfterRetry(error)
shouldClearStoredDeviceTokenAfterRetry(error)
) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
@@ -436,8 +530,11 @@ class GatewaySession(
return tls != null
}
private fun filteredBootstrapHandoffScopes(role: String, scopes: List<String>): List<String>? {
return when (role.trim()) {
private fun filteredBootstrapHandoffScopes(
role: String,
scopes: List<String>,
): List<String>? =
when (role.trim()) {
"node" -> emptyList()
"operator" -> {
val allowedOperatorScopes =
@@ -451,7 +548,6 @@ class GatewaySession(
}
else -> null
}
}
private fun persistBootstrapHandoffToken(
deviceId: String,
@@ -493,20 +589,25 @@ class GatewaySession(
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
val authScopes =
authObj?.get("scopes").asArrayOrNull()
authObj
?.get("scopes")
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull() }
?: emptyList()
if (!deviceToken.isNullOrBlank()) {
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
}
if (shouldPersistBootstrapHandoffTokens(authSource)) {
authObj?.get("deviceTokens").asArrayOrNull()
authObj
?.get("deviceTokens")
.asArrayOrNull()
?.mapNotNull { it.asObjectOrNull() }
?.forEach { tokenEntry ->
val handoffToken = tokenEntry["deviceToken"].asStringOrNull()
val handoffRole = tokenEntry["role"].asStringOrNull()
val handoffScopes =
tokenEntry["scopes"].asArrayOrNull()
tokenEntry["scopes"]
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull() }
?: emptyList()
if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) {
@@ -517,8 +618,10 @@ class GatewaySession(
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
val sessionDefaults =
obj["snapshot"].asObjectOrNull()
?.get("sessionDefaults").asObjectOrNull()
obj["snapshot"]
.asObjectOrNull()
?.get("sessionDefaults")
.asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
}
@@ -665,13 +768,12 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String {
return try {
private suspend fun awaitConnectNonce(): String =
try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (err: Throwable) {
throw IllegalStateException("connect challenge timeout", err)
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
@@ -780,7 +882,7 @@ class GatewaySession(
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
if (
err is GatewayConnectFailure &&
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
) {
reconnectPausedForAuthFailure = true
continue
@@ -791,26 +893,27 @@ class GatewaySession(
}
}
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
val conn =
Connection(
target.endpoint,
target.token,
target.bootstrapToken,
target.password,
target.options,
target.tls,
)
currentConnection = conn
try {
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
canvasHostUrl = null
mainSessionKey = null
private suspend fun connectOnce(target: DesiredConnection) =
withContext(Dispatchers.IO) {
val conn =
Connection(
target.endpoint,
target.token,
target.bootstrapToken,
target.password,
target.options,
target.tls,
)
currentConnection = conn
try {
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
canvasHostUrl = null
mainSessionKey = null
}
}
}
private fun normalizeCanvasHostUrl(
raw: String?,
@@ -821,7 +924,12 @@ class GatewaySession(
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
val scheme =
parsed
?.scheme
?.trim()
.orEmpty()
.ifBlank { "http" }
val suffix = buildUrlSuffix(parsed)
// If raw URL is a non-loopback address and this connection uses TLS,
@@ -833,7 +941,7 @@ class GatewaySession(
!scheme.equals("https", ignoreCase = true) ||
(port > 0 && port != endpoint.port) ||
(port <= 0 && endpoint.port != 443)
)
)
if (needsTlsRewrite) {
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
}
@@ -853,7 +961,12 @@ class GatewaySession(
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
}
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
private fun buildCanvasUrl(
host: String,
scheme: String,
port: Int,
suffix: String,
): String {
val loweredScheme = scheme.lowercase()
val formattedHost = formatGatewayAuthorityHost(host)
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
@@ -886,7 +999,7 @@ class GatewaySession(
explicitGatewayToken
?: if (
explicitPassword == null &&
(explicitBootstrapToken == null || storedToken != null)
(explicitBootstrapToken == null || storedToken != null)
) {
storedToken
} else {
@@ -933,8 +1046,8 @@ class GatewaySession(
detailCode == "AUTH_TOKEN_MISMATCH"
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
return when (error.details?.code) {
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
@@ -942,15 +1055,13 @@ class GatewaySession(
"AUTH_RATE_LIMITED",
"PAIRING_REQUIRED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED" -> true
"DEVICE_IDENTITY_REQUIRED",
-> true
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean {
return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
private fun isTrustedDeviceRetryEndpoint(
endpoint: GatewayEndpoint,
@@ -963,18 +1074,23 @@ class GatewaySession(
}
}
internal fun buildGatewayWebSocketUrl(host: String, port: Int, useTls: Boolean): String {
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
useTls: Boolean,
): String {
val scheme = if (useTls) "wss" else "ws"
return "$scheme://${formatGatewayAuthority(host, port)}"
}
internal fun formatGatewayAuthority(host: String, port: Int): String {
return "${formatGatewayAuthorityHost(host)}:$port"
}
internal fun formatGatewayAuthority(
host: String,
port: Int,
): String = "${formatGatewayAuthorityHost(host)}:$port"
private fun formatGatewayAuthorityHost(host: String): String {
val normalizedHost = host.trim().trim('[', ']')
return if (normalizedHost.contains(":")) "[${normalizedHost}]" else normalizedHost
return if (normalizedHost.contains(":")) "[$normalizedHost]" else normalizedHost
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -13,14 +13,15 @@ import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@@ -54,14 +55,21 @@ fun buildGatewayTlsConfig(
if (params == null) return null
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
@@ -106,18 +114,29 @@ suspend fun probeGatewayTlsFingerprint(
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
return withContext(Dispatchers.IO) {
val trustAll =
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
val fingerprintRef = AtomicReference<String?>(null)
val probeTrustManager =
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("gateway TLS probe does not accept client certificates")
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
fingerprintRef.set(sha256Hex(chain[0].encoded))
throw CertificateException("gateway TLS probe captured fingerprint")
}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustAll), SecureRandom())
context.init(null, arrayOf(probeTrustManager), SecureRandom())
val socket = (context.socketFactory.createSocket() as SSLSocket)
try {
@@ -141,13 +160,16 @@ suspend fun probeGatewayTlsFingerprint(
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
} catch (err: Throwable) {
fingerprintRef.get()?.let { return@withContext GatewayTlsProbeResult(fingerprintSha256 = it) }
val failure =
when (err) {
is SSLException,
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is EOFException,
-> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is ConnectException,
is SocketTimeoutException,
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
is UnknownHostException,
-> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
GatewayTlsProbeResult(failure = failure)
@@ -179,7 +201,9 @@ private fun sha256Hex(data: ByteArray): String {
}
private fun normalizeFingerprint(raw: String): String {
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
val stripped =
raw
.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -13,12 +12,11 @@ class A2UIHandler(
private val getNodeCanvasHostUrl: () -> String?,
private val getOperatorCanvasHostUrl: () -> String?,
) {
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return CanvasActionTrust.isTrustedCanvasActionUrl(
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean =
CanvasActionTrust.isTrustedCanvasActionUrl(
rawUrl = rawUrl,
trustedA2uiUrls = listOfNotNull(resolveA2uiHostUrl()),
)
}
fun resolveA2uiHostUrl(): String? {
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
@@ -26,7 +24,7 @@ class A2UIHandler(
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__openclaw__/a2ui/?platform=android"
return "$base/__openclaw__/a2ui/?platform=android"
}
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@@ -50,7 +48,10 @@ class A2UIHandler(
return false
}
fun decodeA2uiMessages(command: String, paramsJson: String?): String {
fun decodeA2uiMessages(
command: String,
paramsJson: String?,
): String {
val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
@@ -76,8 +77,7 @@ class A2UIHandler(
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
.toList()
}.toList()
return JsonArray(messages).toString()
}
@@ -86,14 +86,17 @@ class A2UIHandler(
arr.mapIndexed { idx, el ->
val msg =
el as? JsonObject
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
?: throw IllegalArgumentException("A2UI messages[$idx]: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
return JsonArray(out).toString()
}
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
private fun validateA2uiV0_8(
msg: JsonObject,
lineNumber: Int,
) {
if (msg.containsKey("createSurface")) {
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
@@ -135,19 +138,18 @@ class A2UIHandler(
})()
"""
fun a2uiApplyMessagesJS(messagesJson: String): String {
return """
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
fun a2uiApplyMessagesJS(messagesJson: String): String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
""".trimIndent()
}
}
}

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
@@ -7,16 +8,15 @@ import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
private const val DEFAULT_CALENDAR_LIMIT = 50
@@ -52,23 +52,30 @@ internal interface CalendarDataSource {
fun hasWritePermission(context: Context): Boolean
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord>
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord
}
private object SystemCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
override fun hasWritePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
override fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, request.startMs)
@@ -89,7 +96,12 @@ private object SystemCalendarDataSource : CalendarDataSource {
val out = mutableListOf<CalendarEventRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val id = cursor.getLong(0)
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
val title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" }
val beginMs = cursor.getLong(2)
val endMs = cursor.getLong(3)
val isAllDay = cursor.getInt(4) == 1
@@ -110,7 +122,10 @@ private object SystemCalendarDataSource : CalendarDataSource {
}
}
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
override fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord {
val resolver = context.contentResolver
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
val values =
@@ -124,10 +139,12 @@ private object SystemCalendarDataSource : CalendarDataSource {
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId = uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
val uri =
resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId =
uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
return loadEventById(resolver, eventId)
?: throw IllegalStateException("calendar insert failed")
}
@@ -149,45 +166,54 @@ private object SystemCalendarDataSource : CalendarDataSource {
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
}
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
private fun calendarExists(
resolver: ContentResolver,
id: Long,
): Boolean {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
}
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
private fun findCalendarByTitle(
resolver: ContentResolver,
title: String,
): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun loadEventById(
@@ -204,24 +230,30 @@ private object SystemCalendarDataSource : CalendarDataSource {
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
)
resolver.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
resolver
.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
}
}
@@ -337,10 +369,12 @@ class CalendarHandler private constructor(
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
val start =
parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end =
parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
return CalendarAddRequest(
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
startMs = start.toEpochMilli(),
@@ -363,8 +397,8 @@ class CalendarHandler private constructor(
}
}
private fun eventJson(event: CalendarEventRecord): JsonObject {
return buildJsonObject {
private fun eventJson(event: CalendarEventRecord): JsonObject =
buildJsonObject {
put("identifier", JsonPrimitive(event.identifier))
put("title", JsonPrimitive(event.title))
put("startISO", JsonPrimitive(event.startISO))
@@ -373,7 +407,6 @@ class CalendarHandler private constructor(
event.location?.let { put("location", JsonPrimitive(it)) }
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
}
}
companion object {
internal fun forTesting(

View File

@@ -1,24 +1,23 @@
package ai.openclaw.app.node
import ai.openclaw.app.PermissionRequester
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.util.Base64
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.CameraInfo
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
@@ -28,22 +27,33 @@ import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import ai.openclaw.app.PermissionRequester
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.math.roundToInt
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
class CameraCaptureManager(
private val context: Context,
) {
data class Payload(
val payloadJson: String,
)
data class FilePayload(
val file: File,
val durationMs: Long,
val hasAudio: Boolean,
)
class CameraCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
data class CameraDeviceInfo(
val id: String,
val name: String,
@@ -52,6 +62,7 @@ class CameraCaptureManager(private val context: Context) {
)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
@Volatile private var permissionRequester: PermissionRequester? = null
fun attachLifecycleOwner(owner: LifecycleOwner) {
@@ -74,8 +85,9 @@ class CameraCaptureManager(private val context: Context) {
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (granted) return
val requester = permissionRequester
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
val requester =
permissionRequester
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
if (results[Manifest.permission.CAMERA] != true) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
@@ -86,8 +98,9 @@ class CameraCaptureManager(private val context: Context) {
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (granted) return
val requester = permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val requester =
permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
if (results[Manifest.permission.RECORD_AUDIO] != true) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
@@ -111,9 +124,10 @@ class CameraCaptureManager(private val context: Context) {
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
val decoded =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val rotated = rotateBitmapByExif(decoded, orientation)
val scaled =
if (maxWidth > 0 && rotated.width > maxWidth) {
@@ -177,23 +191,30 @@ class CameraCaptureManager(private val context: Context) {
val deviceId = parseDeviceId(params)
if (includeAudio) ensureMicPermission()
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
android.util.Log.w(
"CameraCaptureManager",
"clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}",
)
val provider = context.cameraProvider()
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
// Use LOWEST quality for smallest files over WebSocket
val recorder = Recorder.Builder()
.setQualitySelector(
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST))
)
.build()
val recorder =
Recorder
.Builder()
.setQualitySelector(
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST)),
).build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector = resolveCameraSelector(provider, facing, deviceId)
// CameraX requires a Preview use case for the camera to start producing frames;
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
val preview = androidx.camera.core.Preview.Builder().build()
val preview =
androidx.camera.core.Preview
.Builder()
.build()
// Provide a dummy SurfaceTexture so the preview pipeline activates
val surfaceTexture = android.graphics.SurfaceTexture(0)
surfaceTexture.setDefaultBufferSize(640, 480)
@@ -214,7 +235,7 @@ class CameraCaptureManager(private val context: Context) {
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
kotlinx.coroutines.delay(1_500)
val file = File.createTempFile("openclaw-clip-", ".mp4")
val file = File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir)
val outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
@@ -224,14 +245,16 @@ class CameraCaptureManager(private val context: Context) {
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}
.start(context.mainExecutor()) { event ->
}.start(context.mainExecutor()) { event ->
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
if (event is VideoRecordEvent.Status) {
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
}
if (event is VideoRecordEvent.Finalize) {
android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}")
android.util.Log.w(
"CameraCaptureManager",
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
)
finalized.complete(event)
}
}
@@ -254,7 +277,11 @@ class CameraCaptureManager(private val context: Context) {
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause)
android.util.Log.e(
"CameraCaptureManager",
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
finalizeEvent.cause,
)
// Check file size for debugging
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
@@ -271,7 +298,10 @@ class CameraCaptureManager(private val context: Context) {
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
}
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
private fun rotateBitmapByExif(
bitmap: Bitmap,
orientation: Int,
): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
@@ -304,15 +334,13 @@ class CameraCaptureManager(private val context: Context) {
}
}
private fun parseQuality(params: JsonObject?): Double? =
parseJsonDouble(params, "quality")
private fun parseQuality(params: JsonObject?): Double? = parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseDurationMs(params: JsonObject?): Int? = parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
parseJsonString(params, "deviceId")
@@ -335,7 +363,8 @@ class CameraCaptureManager(private val context: Context) {
if (!availableIds.contains(deviceId)) {
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
}
return CameraSelector.Builder()
return CameraSelector
.Builder()
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@@ -372,8 +401,7 @@ class CameraCaptureManager(private val context: Context) {
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraIdOrNull(info: CameraInfo): String? =
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
private fun cameraIdOrNull(info: CameraInfo): String? = runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
}
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
@@ -392,9 +420,12 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
}
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
private suspend fun ImageCapture.takeJpegWithExif(
executor: Executor,
tempDir: File,
): Pair<ByteArray, Int> =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("openclaw-snap-", ".jpg")
val file = File.createTempFile("openclaw-snap-", ".jpg", tempDir)
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
@@ -408,10 +439,11 @@ private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<Byte
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val exif = ExifInterface(file.absolutePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val bytes = file.readBytes()
cont.resume(Pair(bytes, orientation))
} catch (e: Exception) {

View File

@@ -1,9 +1,9 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
@@ -16,8 +16,7 @@ import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
class CameraHandler(
private val appContext: Context,
@@ -27,8 +26,8 @@ class CameraHandler(
private val triggerCameraFlash: () -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
return try {
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
try {
val devices = camera.listDevices()
val payload =
buildJsonObject {
@@ -53,10 +52,10 @@ class CameraHandler(
val (code, message) = invokeErrorFromThrowable(err)
GatewaySession.InvokeResult.error(code = code, message = message)
}
}
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun camLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
@@ -95,6 +94,7 @@ class CameraHandler(
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun clipLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
@@ -133,18 +133,19 @@ class CameraHandler(
)
}
val bytes = withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
val bytes =
withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
}
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
return GatewaySession.InvokeResult.ok(
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""",
)
} catch (err: Throwable) {
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")

View File

@@ -5,7 +5,10 @@ import java.net.URI
object CanvasActionTrust {
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
fun isTrustedCanvasActionUrl(rawUrl: String?, trustedA2uiUrls: List<String>): Boolean {
fun isTrustedCanvasActionUrl(
rawUrl: String?,
trustedA2uiUrls: List<String>,
): Boolean {
val candidate = rawUrl?.trim().orEmpty()
if (candidate.isEmpty()) return false
if (candidate == scaffoldAssetUrl) return true
@@ -21,7 +24,10 @@ object CanvasActionTrust {
}
}
private fun matchesTrustedRemoteA2uiUrlExact(candidateUri: URI, trustedUrl: String): Boolean {
private fun matchesTrustedRemoteA2uiUrlExact(
candidateUri: URI,
trustedUrl: String,
): Boolean {
val trustedUri = parseUri(trustedUrl) ?: return false
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
return candidateUri == normalizedTrusted
@@ -33,7 +39,11 @@ object CanvasActionTrust {
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme != "http" && scheme != "https") return null
val host = uri.host?.trim()?.takeIf { it.isNotEmpty() }?.lowercase() ?: return null
val host =
uri.host
?.trim()
?.takeIf { it.isNotEmpty() }
?.lowercase() ?: return null
return try {
URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null)

View File

@@ -1,39 +1,46 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.WebView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
import kotlinx.coroutines.suspendCancellableCoroutine
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 ai.openclaw.app.BuildConfig
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import kotlin.coroutines.resume
class CanvasController {
enum class SnapshotFormat(val rawValue: String) {
enum class SnapshotFormat(
val rawValue: String,
) {
Png("png"),
Jpeg("jpeg"),
}
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
@Volatile private var homeCanvasStateJson: String? = null
private val _currentUrl = MutableStateFlow<String?>(null)
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
@@ -82,7 +89,10 @@ class CanvasController {
applyDebugStatus()
}
fun setDebugStatus(title: String?, subtitle: String?) {
fun setDebugStatus(
title: String?,
subtitle: String?,
) {
debugStatusTitle = title
debugStatusSubtitle = subtitle
applyDebugStatus()
@@ -131,7 +141,8 @@ class CanvasController {
withWebViewOnMain { wv ->
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
val js = """
val js =
"""
(() => {
try {
const api = globalThis.__openclaw;
@@ -145,7 +156,7 @@ class CanvasController {
}
} catch (_) {}
})();
""".trimIndent()
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
@@ -153,7 +164,8 @@ class CanvasController {
private fun applyHomeCanvasState() {
val payload = homeCanvasStateJson ?: "null"
withWebViewOnMain { wv ->
val js = """
val js =
"""
(() => {
try {
const api = globalThis.__openclaw;
@@ -161,7 +173,7 @@ class CanvasController {
api.renderHome($payload);
} catch (_) {}
})();
""".trimIndent()
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
@@ -194,7 +206,11 @@ class CanvasController {
}
}
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
suspend fun snapshotBase64(
format: SnapshotFormat,
quality: Double?,
maxWidth: Int?,
): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
@@ -230,7 +246,11 @@ class CanvasController {
}
companion object {
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
data class SnapshotParams(
val format: SnapshotFormat,
val quality: Double?,
val maxWidth: Int?,
)
fun parseNavigateUrl(paramsJson: String?): String {
val obj = parseParamsObject(paramsJson) ?: return ""
@@ -269,13 +289,12 @@ class CanvasController {
return q.coerceIn(0.1, 1.0)
}
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
return SnapshotParams(
fun parseSnapshotParams(paramsJson: String?): SnapshotParams =
SnapshotParams(
format = parseSnapshotFormat(paramsJson),
quality = parseSnapshotQuality(paramsJson),
maxWidth = parseSnapshotMaxWidth(paramsJson),
)
}
private val json = Json { ignoreUnknownKeys = true }

View File

@@ -1,15 +1,15 @@
package ai.openclaw.app.node
import android.os.Build
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.VoiceWakeMode
import ai.openclaw.app.gateway.GatewayClientInfo
import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.LocationMode
import ai.openclaw.app.VoiceWakeMode
import android.os.Build
class ConnectionManager(
private val prefs: SecurePrefs,
@@ -115,22 +115,27 @@ class ConnectionManager(
}
}
fun resolveModelIdentifier(): String? {
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
fun resolveModelIdentifier(): String? =
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
}
fun buildUserAgent(): String {
val version = resolvedVersionName()
val release = Build.VERSION.RELEASE?.trim().orEmpty()
val release =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
val releaseLabel = if (release.isEmpty()) "unknown" else release
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
}
fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
return GatewayClientInfo(
fun buildClientInfo(
clientId: String,
clientMode: String,
): GatewayClientInfo =
GatewayClientInfo(
id = clientId,
displayName = prefs.displayName.value,
version = resolvedVersionName(),
@@ -140,10 +145,9 @@ class ConnectionManager(
deviceFamily = "Android",
modelIdentifier = resolveModelIdentifier(),
)
}
fun buildNodeConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
fun buildNodeConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "node",
scopes = emptyList(),
caps = buildCapabilities(),
@@ -152,10 +156,9 @@ class ConnectionManager(
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}
fun buildOperatorConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "operator",
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
caps = emptyList(),
@@ -164,7 +167,6 @@ class ConnectionManager(
client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)

View File

@@ -1,13 +1,12 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -47,23 +46,30 @@ internal interface ContactsDataSource {
fun hasWritePermission(context: Context): Boolean
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
fun search(
context: Context,
request: ContactsSearchRequest,
): List<ContactRecord>
fun add(context: Context, request: ContactsAddRequest): ContactRecord
fun add(
context: Context,
request: ContactsAddRequest,
): ContactRecord
}
private object SystemContactsDataSource : ContactsDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
override fun hasWritePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
override fun search(
context: Context,
request: ContactsSearchRequest,
): List<ContactRecord> {
val resolver = context.contentResolver
val projection =
arrayOf(
@@ -80,37 +86,43 @@ private object SystemContactsDataSource : ContactsDataSource {
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
}
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
resolver.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val out = mutableListOf<ContactRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val contactId = cursor.getLong(idIndex)
val displayName = cursor.getString(displayNameIndex).orEmpty()
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
resolver
.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val out = mutableListOf<ContactRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val contactId = cursor.getLong(idIndex)
val displayName = cursor.getString(displayNameIndex).orEmpty()
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
}
return out
}
return out
}
}
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
override fun add(
context: Context,
request: ContactsAddRequest,
): ContactRecord {
val resolver = context.contentResolver
val operations = ArrayList<ContentProviderOperation>()
operations +=
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
ContentProviderOperation
.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
@@ -120,7 +132,8 @@ private object SystemContactsDataSource : ContactsDataSource {
}
if (!request.organizationName.isNullOrEmpty()) {
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
@@ -128,7 +141,8 @@ private object SystemContactsDataSource : ContactsDataSource {
}
request.phoneNumbers.forEach { number ->
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
@@ -137,7 +151,8 @@ private object SystemContactsDataSource : ContactsDataSource {
}
request.emails.forEach { email ->
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
@@ -146,12 +161,15 @@ private object SystemContactsDataSource : ContactsDataSource {
}
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
val rawContactUri = results.firstOrNull()?.uri
?: throw IllegalStateException("contact insert failed")
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
val rawContactUri =
results.firstOrNull()?.uri
?: throw IllegalStateException("contact insert failed")
val rawContactId =
rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId =
resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
return loadContactRecord(
resolver = resolver,
contactId = contactId,
@@ -159,19 +177,23 @@ private object SystemContactsDataSource : ContactsDataSource {
)
}
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
private fun resolveContactIdForRawContact(
resolver: ContentResolver,
rawContactId: Long,
): Long? {
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
resolver.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
"${ContactsContract.RawContacts._ID}=?",
arrayOf(rawContactId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
return cursor.getLong(index)
}
resolver
.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
"${ContactsContract.RawContacts._ID}=?",
arrayOf(rawContactId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
return cursor.getLong(index)
}
}
private fun loadContactRecord(
@@ -206,69 +228,80 @@ private object SystemContactsDataSource : ContactsDataSource {
val displayName: String?,
)
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
private fun loadNameRow(
resolver: ContentResolver,
contactId: Long,
): NameRow {
val projection =
arrayOf(
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
)
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(
contactId.toString(),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) {
return NameRow(givenName = null, familyName = null, displayName = null)
resolver
.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(
contactId.toString(),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) {
return NameRow(givenName = null, familyName = null, displayName = null)
}
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
return NameRow(givenName = given, familyName = family, displayName = display)
}
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
return NameRow(givenName = given, familyName = family, displayName = display)
}
}
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
private fun loadOrganization(
resolver: ContentResolver,
contactId: Long,
): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getString(0)?.trim()?.ifEmpty { null }
}
resolver
.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getString(0)?.trim()?.ifEmpty { null }
}
}
private fun escapeLikePattern(pattern: String): String =
pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun escapeLikePattern(pattern: String): String = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
private fun loadPhones(
resolver: ContentResolver,
contactId: Long,
): List<String> =
queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
contactId = contactId,
)
}
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
private fun loadEmails(
resolver: ContentResolver,
contactId: Long,
): List<String> =
queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
contactId = contactId,
)
}
private fun queryContactValues(
resolver: ContentResolver,
@@ -278,21 +311,22 @@ private object SystemContactsDataSource : ContactsDataSource {
contactId: Long,
): List<String> {
val projection = arrayOf(valueColumn)
resolver.query(
contentUri,
projection,
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
resolver
.query(
contentUri,
projection,
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
}
return out.toList()
}
return out.toList()
}
}
}
@@ -412,8 +446,8 @@ class ContactsHandler private constructor(
}
}
private fun contactJson(contact: ContactRecord): JsonObject {
return buildJsonObject {
private fun contactJson(contact: ContactRecord): JsonObject =
buildJsonObject {
put("identifier", JsonPrimitive(contact.identifier))
put("displayName", JsonPrimitive(contact.displayName))
put("givenName", JsonPrimitive(contact.givenName))
@@ -422,7 +456,6 @@ class ContactsHandler private constructor(
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
}
}
companion object {
internal fun forTesting(

View File

@@ -1,16 +1,17 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.serialization.json.JsonPrimitive
private const val LOGCAT_PATH = "/system/bin/logcat"
class DebugHandler(
private val appContext: Context,
private val identityStore: DeviceIdentityStore,
) {
fun handleEd25519(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
@@ -40,9 +41,10 @@ class DebugHandler(
// Check available providers
val providers = java.security.Security.getProviders()
val ed25519Providers = providers.filter { p ->
p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) }
}
val ed25519Providers =
providers.filter { p ->
p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) }
}
results.add("Ed25519 providers: ${ed25519Providers.map { "${it.name} v${it.version}" }}")
results.add("Provider order: ${providers.take(5).map { it.name }}")
@@ -65,7 +67,10 @@ class DebugHandler(
val diagnostics = results.joinToString("\n")
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
} catch (e: Throwable) {
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
return GatewaySession.InvokeResult.error(
code = "ED25519_TEST_FAILED",
message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}",
)
}
}
@@ -75,44 +80,67 @@ class DebugHandler(
}
val pid = android.os.Process.myPid()
val rt = Runtime.getRuntime()
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory()/1024}K total=${rt.totalMemory()/1024}K max=${rt.maxMemory()/1024}K uptime=${android.os.SystemClock.elapsedRealtime()/1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
// Run logcat on current dispatcher thread (no withContext) with file redirect
val logResult = try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
if (tmpFile.exists()) tmpFile.delete()
val pb = ProcessBuilder("logcat", "-d", "-t", "200", "--pid=$pid")
pb.redirectOutput(tmpFile)
pb.redirectErrorStream(true)
val proc = pb.start()
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) proc.destroyForcibly()
val raw = if (tmpFile.exists() && tmpFile.length() > 0) {
tmpFile.readText().take(128000)
} else {
"(no output, finished=$finished, exists=${tmpFile.exists()})"
val logResult =
try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
if (tmpFile.exists()) tmpFile.delete()
val pb = ProcessBuilder(LOGCAT_PATH, "-d", "-t", "200", "--pid=$pid")
pb.redirectOutput(tmpFile)
pb.redirectErrorStream(true)
val proc = pb.start()
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) proc.destroyForcibly()
val raw =
if (tmpFile.exists() && tmpFile.length() > 0) {
tmpFile.readText().take(128000)
} else {
"(no output, finished=$finished, exists=${tmpFile.exists()})"
}
tmpFile.delete()
val spamPatterns =
listOf(
"setRequestedFrameRate",
"I View :",
"BLASTBufferQueue",
"VRI[Pop-Up",
"InsetsController:",
"VRI[MainActivity",
"InsetsSource:",
"handleResized",
"ProfileInstaller",
"I VRI[",
"onStateChanged: host=",
"D StrictMode:",
"E StrictMode:",
"ImeFocusController",
"InputTransport",
"IncorrectContextUseViolation",
)
val sb = StringBuilder()
for (line in raw.lineSequence()) {
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) {
sb.append("\n(truncated)")
break
}
if (sb.isNotEmpty()) sb.append('\n')
sb.append(line)
}
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
tmpFile.delete()
val spamPatterns = listOf("setRequestedFrameRate", "I View :", "BLASTBufferQueue", "VRI[Pop-Up",
"InsetsController:", "VRI[MainActivity", "InsetsSource:", "handleResized", "ProfileInstaller",
"I VRI[", "onStateChanged: host=", "D StrictMode:", "E StrictMode:", "ImeFocusController",
"InputTransport", "IncorrectContextUseViolation")
val sb = StringBuilder()
for (line in raw.lineSequence()) {
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) { sb.append("\n(truncated)"); break }
if (sb.isNotEmpty()) sb.append('\n')
sb.append(line)
}
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
// Also include camera debug log if it exists
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
val camLog = if (camLogFile.exists() && camLogFile.length() > 0) {
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
} else ""
val camLog =
if (camLogFile.exists() && camLogFile.length() > 0) {
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
} else {
""
}
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
}
}

View File

@@ -1,6 +1,8 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.app.ActivityManager
import android.content.Context
@@ -16,18 +18,16 @@ import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
) {
companion object {
internal fun hasAnySmsCapability(
@@ -35,19 +35,16 @@ class DeviceHandler(
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
}
): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
): Boolean = smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
@@ -55,21 +52,13 @@ class DeviceHandler(
val temperatureC: Double?,
)
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(statusPayloadJson())
}
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson())
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(infoPayloadJson())
}
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson())
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
}
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson())
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(healthPayloadJson())
}
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
private fun statusPayloadJson(): String {
val battery = readBatterySnapshot()
@@ -133,14 +122,20 @@ class DeviceHandler(
val model = Build.MODEL?.trim().orEmpty()
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
val systemVersion =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
val locale = Locale.getDefault().toLanguageTag().trim()
val appVersion = BuildConfig.VERSION_NAME.trim()
val appBuild = BuildConfig.VERSION_CODE.toString()
return buildJsonObject {
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
put(
"modelIdentifier",
JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }),
)
put("systemName", JsonPrimitive("Android"))
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
@@ -200,7 +195,17 @@ class DeviceHandler(
put(
"status",
JsonPrimitive(
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
if (hasAnySmsCapability(
smsEnabled,
canSendSms,
smsSendGranted,
smsReadGranted,
)
) {
"granted"
} else {
"denied"
},
),
)
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
@@ -367,24 +372,22 @@ class DeviceHandler(
return rawLevel.toDouble() / rawScale.toDouble()
}
private fun mapBatteryState(status: Int): String {
return when (status) {
private fun mapBatteryState(status: Int): String =
when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
else -> "unknown"
}
}
private fun mapChargingType(plugged: Int): String {
return when (plugged) {
private fun mapChargingType(plugged: Int): String =
when (plugged) {
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
else -> "none"
}
}
private fun mapThermalState(powerManager: PowerManager?): String {
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
@@ -394,7 +397,8 @@ class DeviceHandler(
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
PowerManager.THERMAL_STATUS_SHUTDOWN,
-> "critical"
else -> "nominal"
}
}
@@ -408,19 +412,24 @@ class DeviceHandler(
}
}
private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
buildJsonObject {
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
}
private fun hasPermission(permission: String): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
)
private fun permissionStateJson(
granted: Boolean,
promptableWhenDenied: Boolean,
) = buildJsonObject {
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
}
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
private fun hasPermission(permission: String): Boolean =
(
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
)
private fun mapMemoryPressure(
totalBytes: Long,
availableBytes: Long,
lowMemory: Boolean,
): String {
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
if (lowMemory) return "critical"
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()

View File

@@ -1,5 +1,9 @@
package ai.openclaw.app.node
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
import android.app.Notification
import android.app.NotificationManager
import android.app.RemoteInput
@@ -8,10 +12,7 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
import androidx.core.content.edit
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -38,8 +39,8 @@ data class DeviceNotificationEntry(
val isClearable: Boolean,
)
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
return buildJsonObject {
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
buildJsonObject {
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(packageName))
put("postTimeMs", JsonPrimitive(postTimeMs))
@@ -51,7 +52,6 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
category?.let { put("category", JsonPrimitive(it)) }
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
}
data class DeviceNotificationSnapshot(
val enabled: Boolean,
@@ -77,9 +77,7 @@ data class NotificationActionResult(
val message: String? = null,
)
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
return kind == NotificationActionKind.Dismiss
}
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss
private object DeviceNotificationStore {
private val lock = Any()
@@ -193,8 +191,8 @@ class DeviceNotificationListenerService : NotificationListenerService() {
emitNotificationsChanged(payload)
}
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
return notificationChangedPayload(
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? =
notificationChangedPayload(
entry = entry,
change = "posted",
key = entry.key,
@@ -203,7 +201,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
isOngoing = entry.isOngoing,
isClearable = entry.isClearable,
)
}
private fun notificationChangedPayload(
entry: DeviceNotificationEntry?,
@@ -284,28 +281,30 @@ class DeviceNotificationListenerService : NotificationListenerService() {
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
private const val legacyRecentPackagesPref = "notifications.recentPackages"
private const val recentPackagesLimit = 64
@Volatile private var activeService: DeviceNotificationListenerService? = null
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
private fun serviceComponent(context: Context): ComponentName {
return ComponentName(context, DeviceNotificationListenerService::class.java)
}
private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java)
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
nodeEventSink = sink
}
private fun recentPackagesPrefs(context: Context) =
context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
val prefs = recentPackagesPrefs(context)
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
prefs.edit().putString(recentPackagesPref, legacy).remove(legacyRecentPackagesPref).apply()
prefs.edit {
putString(recentPackagesPref, legacy)
remove(legacyRecentPackagesPref)
}
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
prefs.edit().remove(legacyRecentPackagesPref).apply()
prefs.edit { remove(legacyRecentPackagesPref) }
}
}
@@ -325,9 +324,10 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
return DeviceNotificationStore.snapshot(enabled = enabled)
}
fun snapshot(
context: Context,
enabled: Boolean = isAccessEnabled(context),
): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled)
fun requestServiceRebind(context: Context) {
runCatching {
@@ -335,7 +335,10 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
}
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult {
if (!isAccessEnabled(context)) {
return NotificationActionResult(
ok = false,
@@ -343,12 +346,13 @@ class DeviceNotificationListenerService : NotificationListenerService() {
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
)
}
val service = activeService
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_UNAVAILABLE",
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
)
val service =
activeService
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_UNAVAILABLE",
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
)
return service.executeActionInternal(request)
}
@@ -364,13 +368,16 @@ class DeviceNotificationListenerService : NotificationListenerService() {
if (normalized.isEmpty() || normalized == service.packageName) return
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
val prefs = recentPackagesPrefs(service.applicationContext)
val existing = prefs.getString(recentPackagesPref, null).orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
val existing =
prefs
.getString(recentPackagesPref, null)
.orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
val updated = listOf(normalized) + existing
prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
}
}
@@ -393,12 +400,13 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return when (request.kind) {
NotificationActionKind.Open -> {
val pendingIntent = sbn.notification.contentIntent
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no open action",
)
val pendingIntent =
sbn.notification.contentIntent
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no open action",
)
runCatching {
pendingIntent.send()
}.fold(

View File

@@ -1,11 +1,11 @@
package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -221,8 +221,8 @@ object InvokeCommandRegistry {
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> {
return capabilityManifest
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> =
capabilityManifest
.filter { spec ->
when (spec.availability) {
NodeCapabilityAvailability.Always -> true
@@ -233,12 +233,10 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
}
.map { it.name }
}
}.map { it.name }
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> {
return all
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> =
all
.filter { spec ->
when (spec.availability) {
InvokeCommandAvailability.Always -> true
@@ -252,7 +250,5 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}
.map { it.name }
}
}.map { it.name }
}

View File

@@ -2,10 +2,10 @@ package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -34,8 +34,8 @@ internal fun smsSearchAvailabilityError(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): GatewaySession.InvokeResult? {
return when (
): GatewaySession.InvokeResult? =
when (
classifySmsSearchAvailability(
readSmsAvailable = readSmsAvailable,
smsFeatureEnabled = smsFeatureEnabled,
@@ -43,14 +43,14 @@ internal fun smsSearchAvailabilityError(
)
) {
SmsSearchAvailabilityReason.Available,
SmsSearchAvailabilityReason.PermissionRequired -> null
SmsSearchAvailabilityReason.PermissionRequired,
-> null
SmsSearchAvailabilityReason.Unavailable ->
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
}
class InvokeDispatcher(
private val canvas: CanvasController,
@@ -82,7 +82,10 @@ class InvokeDispatcher(
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) {
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
suspend fun handleInvoke(
command: String,
paramsJson: String?,
): GatewaySession.InvokeResult {
val spec =
InvokeCommandRegistry.find(command)
?: return GatewaySession.InvokeResult.error(
@@ -151,7 +154,7 @@ class InvokeDispatcher(
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = err.message ?: "invalid A2UI payload"
message = err.message ?: "invalid A2UI payload",
)
}
withReadyA2ui {
@@ -186,9 +189,10 @@ class InvokeDispatcher(
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
// Photos command
ai.openclaw.app.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
paramsJson,
)
ai.openclaw.app.protocol.OpenClawPhotosCommand.Latest.rawValue ->
photosHandler.handlePhotosLatest(
paramsJson,
)
// Contacts command
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
@@ -216,14 +220,13 @@ class InvokeDispatcher(
}
}
private suspend fun withReadyA2ui(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
private suspend fun withReadyA2ui(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult {
var a2uiUrl =
a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!readyOnFirstCheck) {
if (!refreshNodeCanvasCapability()) {
@@ -247,10 +250,8 @@ class InvokeDispatcher(
return block()
}
private suspend fun withCanvasAvailable(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
return try {
private suspend fun withCanvasAvailable(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult =
try {
block()
} catch (_: Throwable) {
GatewaySession.InvokeResult.error(
@@ -258,10 +259,9 @@ class InvokeDispatcher(
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
}
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
return when (availability) {
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? =
when (availability) {
InvokeCommandAvailability.Always -> null
InvokeCommandAvailability.CameraEnabled ->
if (cameraEnabled()) {
@@ -309,7 +309,8 @@ class InvokeDispatcher(
)
}
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
InvokeCommandAvailability.RequestableSmsSearchAvailable,
->
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
@@ -334,5 +335,4 @@ class InvokeDispatcher(
)
}
}
}
}

View File

@@ -30,7 +30,13 @@ internal object JpegSizeLimiter {
var width = initialWidth
var height = initialHeight
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts) {

View File

@@ -7,15 +7,19 @@ import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.suspendCancellableCoroutine
import java.time.Instant
import java.time.format.DateTimeFormatter
class LocationCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
class LocationCaptureManager(
private val context: Context,
) {
data class Payload(
val payloadJson: String,
)
suspend fun getLocation(
desiredProviders: List<String>,
@@ -98,15 +102,16 @@ class LocationCaptureManager(private val context: Context) {
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
val location = withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
cont.resume(location) { _, _, _ -> }
val location =
withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
cont.resume(location) { _, _, _ -> }
}
}
}
}
return location ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no fix")
}
}

View File

@@ -1,11 +1,11 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.Context
import android.hardware.Sensor
@@ -8,17 +9,15 @@ import android.hardware.SensorEventListener
import android.hardware.SensorManager
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
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
@@ -67,9 +66,15 @@ internal interface MotionDataSource {
fun hasPermission(context: Context): Boolean
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
suspend fun activity(
context: Context,
request: MotionActivityRequest,
): MotionActivityRecord
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
suspend fun pedometer(
context: Context,
request: MotionPedometerRequest,
): PedometerRecord
}
private object SystemMotionDataSource : MotionDataSource {
@@ -83,22 +88,27 @@ private object SystemMotionDataSource : MotionDataSource {
return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
override fun hasPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
override fun hasPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
override suspend fun activity(
context: Context,
request: MotionActivityRequest,
): MotionActivityRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
}
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
val sensorManager =
context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
val accelerometer =
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
val sample = readAccelerometerSample(sensorManager, accelerometer)
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
val sample =
readAccelerometerSample(sensorManager, accelerometer)
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
val end = Instant.now()
val start = end.minusSeconds(2)
val classification = classifyActivity(sample.averageDelta)
@@ -115,17 +125,23 @@ private object SystemMotionDataSource : MotionDataSource {
)
}
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
override suspend fun pedometer(
context: Context,
request: MotionPedometerRequest,
): PedometerRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
}
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
val sensorManager =
context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
val stepCounter =
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
val steps = readStepCounter(sensorManager, stepCounter)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
val steps =
readStepCounter(sensorManager, stepCounter)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
return PedometerRecord(
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
@@ -143,7 +159,10 @@ private object SystemMotionDataSource : MotionDataSource {
)
@OptIn(InternalCoroutinesApi::class)
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
private suspend fun readStepCounter(
sensorManager: SensorManager,
sensor: Sensor,
): Int? {
val sample =
withTimeoutOrNull(1200L) {
suspendCancellableCoroutine<Float?> { cont ->
@@ -156,7 +175,10 @@ private object SystemMotionDataSource : MotionDataSource {
sensorManager.unregisterListener(this)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
@@ -194,17 +216,21 @@ private object SystemMotionDataSource : MotionDataSource {
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
val result = AccelerometerSample(
samples = count,
averageDelta = sumDelta / count,
)
val result =
AccelerometerSample(
samples = count,
averageDelta = sumDelta / count,
)
val token = cont.tryResume(result) ?: return
cont.completeResume(token)
sensorManager.unregisterListener(this)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
@@ -217,15 +243,17 @@ private object SystemMotionDataSource : MotionDataSource {
return sample
}
private fun classifyActivity(averageDelta: Double): String {
return when {
private fun classifyActivity(averageDelta: Double): String =
when {
averageDelta <= 0.55 -> "stationary"
averageDelta <= 1.80 -> "walking"
else -> "running"
}
}
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
private fun classifyConfidence(
samples: Int,
averageDelta: Double,
): String {
if (samples < 6) return "low"
if (samples >= 14 && averageDelta > 0.4) return "high"
return "medium"

View File

@@ -0,0 +1,98 @@
package ai.openclaw.app.node
import android.os.Build
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
internal object NodePresenceAliveBeacon {
const val EVENT_NAME: String = "node.presence.alive"
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
enum class Trigger(
val rawValue: String,
) {
Background("background"),
SilentPush("silent_push"),
BackgroundAppRefresh("bg_app_refresh"),
SignificantLocation("significant_location"),
Manual("manual"),
Connect("connect"),
}
data class ResponsePayload(
val ok: Boolean?,
val event: String?,
val handled: Boolean?,
val reason: String?,
)
private val json = Json { ignoreUnknownKeys = true }
fun shouldSkipRecentSuccess(
nowMs: Long,
lastSuccessAtMs: Long?,
minIntervalMs: Long = MIN_SUCCESS_INTERVAL_MS,
): Boolean {
val last = lastSuccessAtMs ?: return false
if (last <= 0) return false
val elapsed = nowMs - last
return elapsed >= 0 && elapsed < minIntervalMs
}
fun androidPlatformLabel(): String {
val release =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
.ifEmpty { "unknown" }
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
}
fun makePayloadJson(
trigger: Trigger,
sentAtMs: Long,
displayName: String,
version: String,
platform: String,
deviceFamily: String?,
modelIdentifier: String?,
pushTransport: String? = null,
): String =
buildJsonObject {
put("trigger", JsonPrimitive(trigger.rawValue))
put("sentAtMs", JsonPrimitive(sentAtMs))
put("displayName", JsonPrimitive(displayName))
put("version", JsonPrimitive(version))
put("platform", JsonPrimitive(platform))
deviceFamily?.trim()?.takeIf { it.isNotEmpty() }?.let { put("deviceFamily", JsonPrimitive(it)) }
modelIdentifier?.trim()?.takeIf { it.isNotEmpty() }?.let { put("modelIdentifier", JsonPrimitive(it)) }
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
}.toString()
fun decodeResponse(payloadJson: String?): ResponsePayload? {
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
val obj =
try {
json.parseToJsonElement(raw).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
return ResponsePayload(
ok = parseJsonBooleanFlag(obj, "ok"),
event = parseJsonString(obj, "event"),
handled = parseJsonBooleanFlag(obj, "handled"),
reason = parseJsonString(obj, "reason"),
)
}
fun sanitizeReasonForLog(raw: String?): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
return value
.map { ch -> if (ch.isISOControl()) ' ' else ch }
.joinToString("")
.take(200)
}
}

View File

@@ -10,11 +10,17 @@ import kotlinx.serialization.json.contentOrNull
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
data class Quad<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
fun String.toJsonString(): String {
val escaped =
this.replace("\\", "\\\\")
this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
@@ -32,18 +38,30 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
}
}
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun readJsonPrimitive(
params: JsonObject?,
key: String,
): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun parseJsonInt(params: JsonObject?, key: String): Int? =
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonInt(
params: JsonObject?,
key: String,
): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonDouble(
params: JsonObject?,
key: String,
): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonString(params: JsonObject?, key: String): String? =
readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonString(
params: JsonObject?,
key: String,
): String? = readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
fun parseJsonBooleanFlag(
params: JsonObject?,
key: String,
): Boolean? {
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"true" -> true
@@ -79,6 +97,4 @@ fun normalizeMainKey(raw: String?): String? {
return if (trimmed.isEmpty()) null else trimmed
}
fun isCanonicalMainSessionKey(key: String): Boolean {
return key == "main"
}
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -1,7 +1,7 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -15,7 +15,10 @@ internal interface NotificationsStateProvider {
fun requestServiceRebind(context: Context)
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
@@ -35,9 +38,10 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
DeviceNotificationListenerService.requestServiceRebind(context)
}
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
return DeviceNotificationListenerService.executeAction(context, request)
}
override fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request)
}
class NotificationsHandler private constructor(
@@ -54,11 +58,12 @@ class NotificationsHandler private constructor(
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
readSnapshotWithRebind()
val params = parseParamsObject(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val params =
parseParamsObject(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val key =
readString(params, "key")
?: return GatewaySession.InvokeResult.error(
@@ -123,8 +128,8 @@ class NotificationsHandler private constructor(
return snapshot
}
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
return buildJsonObject {
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String =
buildJsonObject {
put("enabled", JsonPrimitive(snapshot.enabled))
put("connected", JsonPrimitive(snapshot.connected))
put("count", JsonPrimitive(snapshot.notifications.size))
@@ -135,7 +140,6 @@ class NotificationsHandler private constructor(
),
)
}.toString()
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
@@ -146,7 +150,10 @@ class NotificationsHandler private constructor(
}
}
private fun readString(params: JsonObject, key: String): String? =
private fun readString(
params: JsonObject,
key: String,
): String? =
(params[key] as? JsonPrimitive)
?.contentOrNull
?.trim()

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
@@ -12,17 +13,15 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import ai.openclaw.app.gateway.GatewaySession
import java.io.ByteArrayOutputStream
import java.time.Instant
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.ByteArrayOutputStream
import java.time.Instant
import kotlin.math.max
import kotlin.math.roundToInt
private const val DEFAULT_PHOTOS_LIMIT = 1
private const val DEFAULT_PHOTOS_MAX_WIDTH = 1600
@@ -47,7 +46,10 @@ internal data class EncodedPhotoPayload(
internal interface PhotosDataSource {
fun hasPermission(context: Context): Boolean
fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload>
fun latest(
context: Context,
request: PhotosLatestRequest,
): List<EncodedPhotoPayload>
}
private object SystemPhotosDataSource : PhotosDataSource {
@@ -61,7 +63,10 @@ private object SystemPhotosDataSource : PhotosDataSource {
return ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> {
override fun latest(
context: Context,
request: PhotosLatestRequest,
): List<EncodedPhotoPayload> {
val resolver = context.contentResolver
val rows = queryLatestRows(resolver, request.limit)
if (rows.isEmpty()) return emptyList()
@@ -102,7 +107,10 @@ private object SystemPhotosDataSource : PhotosDataSource {
val height: Int,
)
private fun queryLatestRows(resolver: ContentResolver, limit: Int): List<PhotoRow> {
private fun queryLatestRows(
resolver: ContentResolver,
limit: Int,
): List<PhotoRow> {
val projection =
arrayOf(
MediaStore.Images.Media._ID,
@@ -117,29 +125,30 @@ private object SystemPhotosDataSource : PhotosDataSource {
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
}
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
args,
null,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val rows = mutableListOf<PhotoRow>()
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
rows +=
PhotoRow(
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
createdAtMs = takenMs ?: addedMs,
)
resolver
.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
args,
null,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val rows = mutableListOf<PhotoRow>()
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
rows +=
PhotoRow(
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
createdAtMs = takenMs ?: addedMs,
)
}
return rows
}
return rows
}
}
private fun decodeScaledBitmap(
@@ -171,7 +180,10 @@ private object SystemPhotosDataSource : PhotosDataSource {
}
}
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
private fun computeInSampleSize(
width: Int,
maxWidth: Int,
): Int {
var sample = 1
var candidate = width
while (candidate > maxWidth && sample < 64) {

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -9,7 +10,6 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -47,7 +47,8 @@ private class AndroidSystemNotificationPoster(
val channelId = ensureChannel(request.priority)
val silent = isSilentSound(request.sound)
val notification =
NotificationCompat.Builder(appContext, channelId)
NotificationCompat
.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(request.title)
.setContentText(request.body)
@@ -83,13 +84,12 @@ private class AndroidSystemNotificationPoster(
return channelId
}
private fun compatPriority(priority: String?): Int {
return when (priority.orEmpty().trim().lowercase()) {
private fun compatPriority(priority: String?): Int =
when (priority.orEmpty().trim().lowercase()) {
"passive" -> NotificationCompat.PRIORITY_LOW
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
}
}
private fun isSilentSound(sound: String?): Boolean {
val normalized = sound?.trim()?.lowercase() ?: return false

View File

@@ -57,13 +57,16 @@ object OpenClawCanvasA2UIAction {
).joinToString(separator = " ")
}
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
fun jsDispatchA2UIActionStatus(
actionId: String,
ok: Boolean,
error: String?,
): String {
val err = jsonStringLiteral(error ?: "")
val okLiteral = if (ok) "true" else "false"
val idLiteral = jsonStringLiteral(actionId)
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: ${idLiteral}, ok: ${okLiteral}, error: ${err} } }));"
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")
private fun jsonStringLiteral(raw: String): String = JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
}

View File

@@ -1,6 +1,8 @@
package ai.openclaw.app.protocol
enum class OpenClawCapability(val rawValue: String) {
enum class OpenClawCapability(
val rawValue: String,
) {
Canvas("canvas"),
Camera("camera"),
Sms("sms"),
@@ -16,7 +18,9 @@ enum class OpenClawCapability(val rawValue: String) {
CallLog("callLog"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
enum class OpenClawCanvasCommand(
val rawValue: String,
) {
Present("canvas.present"),
Hide("canvas.hide"),
Navigate("canvas.navigate"),
@@ -29,7 +33,9 @@ enum class OpenClawCanvasCommand(val rawValue: String) {
}
}
enum class OpenClawCanvasA2UICommand(val rawValue: String) {
enum class OpenClawCanvasA2UICommand(
val rawValue: String,
) {
Push("canvas.a2ui.push"),
PushJSONL("canvas.a2ui.pushJSONL"),
Reset("canvas.a2ui.reset"),
@@ -40,7 +46,9 @@ enum class OpenClawCanvasA2UICommand(val rawValue: String) {
}
}
enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawCameraCommand(
val rawValue: String,
) {
List("camera.list"),
Snap("camera.snap"),
Clip("camera.clip"),
@@ -51,7 +59,9 @@ enum class OpenClawCameraCommand(val rawValue: String) {
}
}
enum class OpenClawSmsCommand(val rawValue: String) {
enum class OpenClawSmsCommand(
val rawValue: String,
) {
Send("sms.send"),
Search("sms.search"),
;
@@ -61,7 +71,9 @@ enum class OpenClawSmsCommand(val rawValue: String) {
}
}
enum class OpenClawLocationCommand(val rawValue: String) {
enum class OpenClawLocationCommand(
val rawValue: String,
) {
Get("location.get"),
;
@@ -70,7 +82,9 @@ enum class OpenClawLocationCommand(val rawValue: String) {
}
}
enum class OpenClawDeviceCommand(val rawValue: String) {
enum class OpenClawDeviceCommand(
val rawValue: String,
) {
Status("device.status"),
Info("device.info"),
Permissions("device.permissions"),
@@ -82,7 +96,9 @@ enum class OpenClawDeviceCommand(val rawValue: String) {
}
}
enum class OpenClawNotificationsCommand(val rawValue: String) {
enum class OpenClawNotificationsCommand(
val rawValue: String,
) {
List("notifications.list"),
Actions("notifications.actions"),
;
@@ -92,7 +108,9 @@ enum class OpenClawNotificationsCommand(val rawValue: String) {
}
}
enum class OpenClawSystemCommand(val rawValue: String) {
enum class OpenClawSystemCommand(
val rawValue: String,
) {
Notify("system.notify"),
;
@@ -101,7 +119,9 @@ enum class OpenClawSystemCommand(val rawValue: String) {
}
}
enum class OpenClawPhotosCommand(val rawValue: String) {
enum class OpenClawPhotosCommand(
val rawValue: String,
) {
Latest("photos.latest"),
;
@@ -110,7 +130,9 @@ enum class OpenClawPhotosCommand(val rawValue: String) {
}
}
enum class OpenClawContactsCommand(val rawValue: String) {
enum class OpenClawContactsCommand(
val rawValue: String,
) {
Search("contacts.search"),
Add("contacts.add"),
;
@@ -120,7 +142,9 @@ enum class OpenClawContactsCommand(val rawValue: String) {
}
}
enum class OpenClawCalendarCommand(val rawValue: String) {
enum class OpenClawCalendarCommand(
val rawValue: String,
) {
Events("calendar.events"),
Add("calendar.add"),
;
@@ -130,7 +154,9 @@ enum class OpenClawCalendarCommand(val rawValue: String) {
}
}
enum class OpenClawMotionCommand(val rawValue: String) {
enum class OpenClawMotionCommand(
val rawValue: String,
) {
Activity("motion.activity"),
Pedometer("motion.pedometer"),
;
@@ -140,7 +166,9 @@ enum class OpenClawMotionCommand(val rawValue: String) {
}
}
enum class OpenClawCallLogCommand(val rawValue: String) {
enum class OpenClawCallLogCommand(
val rawValue: String,
) {
Search("callLog.search"),
;

View File

@@ -48,13 +48,14 @@ data class ToolDisplaySummary(
}
val summaryLine: String
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
@@ -112,7 +113,11 @@ object ToolDisplayRegistry {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val jsonString =
context.assets
.open(CONFIG_ASSET)
.bufferedReader()
.use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
@@ -130,8 +135,11 @@ object ToolDisplayRegistry {
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
if (part.length <= 2 && part == upper) {
part
} else {
upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
}
@@ -147,17 +155,18 @@ object ToolDisplayRegistry {
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"${path}:${offset.toInt()}-${end.toInt()}"
"$path:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun pathDetail(args: JsonObject?): String? = args?.get("path")?.asStringOrNull()
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
private fun firstValue(
args: JsonObject?,
keys: List<String>,
): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
@@ -166,7 +175,10 @@ object ToolDisplayRegistry {
return null
}
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
private fun valueForPath(
args: JsonObject?,
path: String,
): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
@@ -182,7 +194,12 @@ object ToolDisplayRegistry {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
val firstLine =
trimmed
.lineSequence()
.firstOrNull()
?.trim()
.orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}" else firstLine
}
@@ -195,16 +212,18 @@ object ToolDisplayRegistry {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "${preview}" else preview
return if (items.size > 3) "$preview" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
val home =
System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value.replace(home, "~")
return value
.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}

View File

@@ -1,10 +1,11 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import android.annotation.SuppressLint
import android.net.Uri
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -14,135 +15,161 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import ai.openclaw.app.MainViewModel
import java.util.concurrent.atomic.AtomicReference
@SuppressLint("SetJavaScriptEnabled")
@Suppress("DEPRECATION")
@Composable
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, 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) }
val webViewRef = remember { arrayOfNulls<WebView>(1) }
val currentPageUrlRef = remember { AtomicReference<String?>(null) }
DisposableEffect(viewModel) {
onDispose {
val webView = webViewRef.value ?: return@onDispose
val webView = webViewRef[0] ?: return@onDispose
viewModel.canvas.detach(webView)
webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName)
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.removeWebMessageListener(webView, CanvasA2UIActionBridge.interfaceName)
}
webView.stopLoading()
webView.destroy()
webViewRef.value = null
webViewRef[0] = null
}
}
AndroidView(
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
settings.useWideViewPort = false
settings.loadWithOverviewMode = false
settings.builtInZoomControls = false
settings.displayZoomControls = false
settings.setSupportZoom(false)
// targetSdk 33+ ignores Force Dark APIs, so only opt out through the supported
// algorithmic darkening flag when this WebView implementation exposes it.
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: android.graphics.Bitmap?,
) {
currentPageUrlRef.set(url)
}
val webView = WebView(context)
val webSettings = webView.settings
webSettings.setAllowContentAccess(false)
webSettings.setAllowFileAccess(false)
webSettings.setAllowFileAccessFromFileURLs(false)
webSettings.setAllowUniversalAccessFromFileURLs(false)
webSettings.setSafeBrowsingEnabled(true)
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webSettings.useWideViewPort = false
webSettings.loadWithOverviewMode = false
webSettings.builtInZoomControls = false
webSettings.displayZoomControls = false
webSettings.setSupportZoom(false)
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
// targetSdk 33+ ignores Force Dark APIs, so only opt out through the supported
// algorithmic darkening flag when this WebView implementation exposes it.
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webSettings, false)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${webSettings.userAgentString}")
}
webView.isScrollContainer = true
webView.overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
webView.isVerticalScrollBarEnabled = true
webView.isHorizontalScrollBarEnabled = true
webView.webViewClient =
object : WebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: android.graphics.Bitmap?,
) {
currentPageUrlRef.set(url)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(
view: WebView,
url: String?,
) {
currentPageUrlRef.set(url)
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
currentPageUrlRef.set(url)
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
return true
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
webView.webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
) { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
webViewRef.value = this
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
) { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
CanvasA2UIActionBridge.interfaceName,
CanvasA2UIActionBridge.allowedOriginRules,
bridge,
)
} else if (isDebuggable) {
Log.w("OpenClawWebView", "WebMessageListener unsupported; canvas actions disabled")
}
viewModel.canvas.attach(webView)
webViewRef[0] = webView
webView
},
update = { webView ->
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
@@ -160,8 +187,18 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
internal class CanvasA2UIActionBridge(
private val isTrustedPage: () -> Boolean,
private val onMessage: (String) -> Unit,
) {
@JavascriptInterface
) : WebViewCompat.WebMessageListener {
override fun onPostMessage(
view: WebView,
message: WebMessageCompat,
sourceOrigin: Uri,
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return
postMessage(message.data)
}
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
@@ -171,5 +208,6 @@ internal class CanvasA2UIActionBridge(
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
val allowedOriginRules: Set<String> = setOf("*")
}
}

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