Compare commits

...

1917 Commits

Author SHA1 Message Date
Peter Steinberger
213a704b71 fix: unblock 2026.3.31 release preflight 2026-03-31 21:54:12 +01:00
Vincent Koc
44baf3bb2b build(pnpm): exclude openclaw from minimum release age 2026-04-01 05:50:35 +09:00
Vincent Koc
107fefd255 docs(changelog): note pi tui reply flush fix 2026-04-01 05:47:10 +09:00
Peter Steinberger
6f111516ef docs: refresh plugin sdk api baseline 2026-03-31 21:46:21 +01:00
Vincent Koc
f425ea06bf fix(pi): flush message-boundary block replies on message end 2026-04-01 05:44:10 +09:00
Vincent Koc
0a7024e209 test(tasks): allow status command task-registry import 2026-04-01 05:42:45 +09:00
Peter Steinberger
418fa12dfa fix: make overload failover configurable 2026-03-31 21:34:35 +01:00
Peter Steinberger
2a60e34f2a build: prepare 2026.3.31 stable release 2026-03-31 21:32:38 +01:00
Peter Steinberger
eee37bf836 fix(slack): prevent duplicate draft replies 2026-03-31 21:22:50 +01:00
Vincent Koc
fc169215d7 fix(exec): deliver approval followups directly to chat 2026-04-01 05:22:35 +09:00
Vincent Koc
58ee76fc84 feat(status): show session task counts in slash status 2026-04-01 05:09:37 +09:00
Peter Steinberger
913e7d5eba fix: correct callable plugin sdk facades 2026-03-31 21:00:52 +01:00
Vincent Koc
a4f45c55b2 fix(slack): restore callable directory facade exports 2026-04-01 04:51:36 +09:00
Peter Steinberger
ffa2143d20 build: refresh slack facade output 2026-03-31 20:40:31 +01:00
Peter Steinberger
49458fc50e fix: pin parallels version checks to packed build commit 2026-03-31 20:39:21 +01:00
Peter Steinberger
0d742c3c1b test: skip unavailable live model providers 2026-03-31 20:37:42 +01:00
Peter Steinberger
64091caf8f fix: preserve cli and slack fallback behavior 2026-03-31 20:37:42 +01:00
Peter Steinberger
9d1b443542 fix: harden live docker auth harness 2026-03-31 20:37:42 +01:00
Jacob Tomlinson
1216ecbe58 Docs: add missing changelog entries 2026-03-31 19:30:43 +00:00
Vincent Koc
6f5f0c065b fix(slack): restore callable directory facade exports 2026-04-01 04:29:10 +09:00
Vincent Koc
2e530fc2e1 revert(amazon-bedrock): restore locked aws sdk version 2026-04-01 04:26:27 +09:00
Peter Steinberger
ce58f55fe0 fix: require doctor migration for legacy web search config 2026-04-01 04:22:41 +09:00
Peter Steinberger
2001603020 fix(amazon-bedrock): pin installable aws sdk build 2026-03-31 20:19:33 +01:00
Vincent Koc
b441e59d25 fix(build): isolate bundled runtime dependency staging 2026-04-01 04:15:08 +09:00
Peter Steinberger
cc278a76a4 test(tasks): update registry seam allowlist 2026-03-31 20:08:42 +01:00
Peter Steinberger
a23b4dd5bc fix(ci): route task executor through runtime seam 2026-03-31 20:02:10 +01:00
Vincent Koc
2a72a6d507 test(tasks): allow task executor registry seam 2026-04-01 04:01:06 +09:00
Peter Steinberger
d0bcd86348 fix(tasks): restore registry build compatibility 2026-03-31 19:59:21 +01:00
Vincent Koc
bb912daaed test(openai): fix ModelRegistry constructor typing 2026-04-01 03:58:55 +09:00
Vincent Koc
1816d6a4ed fix(tasks): normalize task create compatibility 2026-04-01 03:54:42 +09:00
Vincent Koc
80ed55332d fix(tasks): restore owner-key task scope 2026-04-01 03:53:12 +09:00
Peter Steinberger
5fdde9b237 fix(tasks): restore session key registry compatibility 2026-03-31 19:48:51 +01:00
Peter Steinberger
2d2c271cdf build: exclude source maps from release tarball 2026-03-31 19:42:56 +01:00
Vincent Koc
338d313043 fix(tasks): scope shared run updates by session 2026-04-01 03:41:29 +09:00
Peter Steinberger
8fa5ac5a96 build: refresh plugin sdk api baseline 2026-03-31 19:37:05 +01:00
Peter Steinberger
6f7629995c build: refresh beta release generated artifacts 2026-03-31 19:32:55 +01:00
Peter Steinberger
91be36ca4f build: prepare 2026.3.31-beta.1 release 2026-03-31 19:32:49 +01:00
Peter Steinberger
62a39381da fix: land codex native search follow-ups (#46579) (thanks @Evizero) 2026-04-01 03:30:06 +09:00
Christof Salis
13f1190149 CLI: suppress Codex native search summary when web search is off 2026-04-01 03:30:06 +09:00
Christof Salis
0a891543c9 Codex: use model-level API for native search relevance 2026-04-01 03:30:06 +09:00
Christof Salis
797a70fd95 Codex: add native web search for embedded Pi runs 2026-04-01 03:30:06 +09:00
Peter Steinberger
b87f33c920 test(ci): deflake windows npm exec coverage 2026-03-31 19:28:11 +01:00
Vincent Koc
66413487c8 fix(tasks): make task-store writes atomic (#58521) 2026-04-01 03:23:06 +09:00
Peter Steinberger
0abd143d37 test: fix CI flakes and registry test API 2026-03-31 19:20:07 +01:00
Peter Steinberger
b7013ec207 docs: fix changelog release placement 2026-03-31 19:14:57 +01:00
Vincent Koc
cb661122e2 test(bluebubbles): trim webhook auth import cost 2026-04-01 03:12:56 +09:00
Vincent Koc
e5537f8b64 test(ci): rebalance bluebubbles webhook auth timing 2026-04-01 03:12:56 +09:00
Vincent Koc
6d59ce366e test(ci): rebalance matrix extension timings 2026-04-01 03:12:56 +09:00
Vincent Koc
3ec143254f test(ci): rebalance discord webhook activity timing 2026-04-01 03:12:56 +09:00
Vincent Koc
7cd0ff2d88 refactor(tasks): add owner-key task access boundaries (#58516)
* refactor(tasks): add owner-key task access boundaries

* test(acp): update task owner-key assertion

* fix(tasks): align owner key checks and migration scope
2026-04-01 03:12:33 +09:00
Nimrod Gutman
69fe999373 fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
* fix(pairing): restore qr bootstrap onboarding handoff

* fix(pairing): tighten bootstrap handoff follow-ups

* fix(pairing): migrate legacy gateway device auth

* fix(pairing): narrow qr bootstrap handoff scope

* fix(pairing): clear ios tls trust on onboarding reset

* fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
2026-03-31 21:11:35 +03:00
Peter Steinberger
693d17c4a2 fix: support edit tool edits[] payloads 2026-03-31 19:05:44 +01:00
Peter Steinberger
ae730d9a86 fix: cover Azure disabled reasoning omission (#58208) (thanks @jalehman) 2026-04-01 02:47:29 +09:00
Josh Lehman
a1cb2bdc57 OpenAI: omit disabled reasoning payloads
Strip `reasoning.effort: "none"` from OpenAI-compatible payloads so GPT-5 models do not receive the unsupported value when thinking is off. Cover both the shared payload wrapper path and the websocket `response.create` builder with regression tests.

Regeneration-Prompt: |
  Fix the OpenAI request-building bug where thinking set to off still forwards
  a reasoning payload with effort "none". GPT-5 mini rejects that value with a
  400, so disabled reasoning must be represented by omitting the reasoning
  parameter entirely. Keep the change additive: sanitize OpenAI-compatible
  payloads in OpenClaw’s wrapper layer, make the websocket request builder stop
  emitting a reasoning block for "none", and add focused regression tests for
  both code paths.
2026-04-01 02:47:29 +09:00
Peter Steinberger
62e13bbf21 style: format sandbox and helper files 2026-03-31 18:44:39 +01:00
Peter Steinberger
5e0e46f405 style: format config UI templates 2026-03-31 18:44:05 +01:00
Peter Steinberger
b4433a1bfe fix: normalize raw MCP schemas for OpenAI Responses (#58299) (thanks @yelog) 2026-04-01 02:30:45 +09:00
yelog
dd3796aef3 fix: normalize MCP tool schemas missing properties field for OpenAI Responses API
Tools with no parameters produce { type: "object" } schemas without a
properties field. The OpenAI Responses API rejects these, silently
crashing entire sessions.

Add properties: {} injection in normalizeToolParameters() and
convertTools() to ensure all object-type schemas include a properties
field.

Closes #58246
2026-04-01 02:30:45 +09:00
Vincent Koc
fcb802e826 refactor(plugins): remove before_install hook 2026-04-01 02:28:06 +09:00
Vincent Koc
1a313caff3 refactor(tasks): remove flow registry layer 2026-04-01 02:25:13 +09:00
Vincent Koc
76b3235207 chore(changelog): soften sdk removal note 2026-04-01 02:21:24 +09:00
Vincent Koc
5c9408d3ca docs: update docs for unreleased channel and gateway changes
Cover Teams member-info action, Teams/Matrix sender-allowlist
context filtering, macOS MagicDNS discovery preference, and
trusted-proxy mixed token config hardening.
2026-04-01 02:20:44 +09:00
Peter Steinberger
e039c72a76 fix: unblock onboard docker smoke 2026-03-31 18:12:00 +01:00
Vincent Koc
bfba84a69d chore(changelog): attribute task entries 2026-04-01 02:10:39 +09:00
Peter Steinberger
4b1c15d059 docs: simplify install policy changelog note 2026-03-31 18:09:33 +01:00
Vincent Koc
c03e2beca1 chore(changelog): rename flow entries to tasks 2026-04-01 02:08:46 +09:00
Peter Steinberger
539ba0d244 docs: merge install breaking change notes 2026-03-31 18:07:35 +01:00
Peter Steinberger
b9f8bb6308 docs: tune unreleased changelog priorities 2026-03-31 18:04:22 +01:00
Vincent Koc
d794c5ca56 chore(changelog): add missing breaking notes 2026-04-01 02:01:48 +09:00
Vincent Koc
8ef81cc983 chore(changelog): reorder unreleased sections 2026-04-01 02:00:14 +09:00
Vincent Koc
dc948bb4eb chore(changelog): sort unreleased entries 2026-04-01 01:59:05 +09:00
Peter Steinberger
cebe697082 docs: update qq bot channel docs 2026-03-31 17:55:41 +01:00
Peter Steinberger
7672e48c19 docs: fill changelog gaps since last release 2026-03-31 17:50:00 +01:00
Peter Steinberger
1e17a96983 chore: surface parallels npm-update progress 2026-03-31 17:49:24 +01:00
Peter Steinberger
28673a9388 fix: restore bundled plugin runtime deps after global install 2026-03-31 17:49:24 +01:00
Peter Steinberger
9e8129907e test(tasks): fix relative mocks in task runtime tests 2026-03-31 17:42:52 +01:00
Peter Steinberger
83038fcaf2 docs: add missing unreleased changelog entries 2026-03-31 17:42:05 +01:00
Vincent Koc
35c6b3f648 test(ci): mock googlechat action media loader 2026-04-01 01:32:24 +09:00
Peter Steinberger
8bf8baef87 Revert "refactor: move tasks into bundled plugin"
This reverts commit c75f4695b7.
2026-04-01 01:30:22 +09:00
Peter Steinberger
759d37635d Revert "refactor: move tasks behind plugin-sdk seam"
This reverts commit da6e9bb76f.
2026-04-01 01:30:22 +09:00
Vincent Koc
6f74a572d9 test(ci): fix outbound media loader seams 2026-04-01 01:17:08 +09:00
Jacob Tomlinson
8a563d603b fix(matrix): filter fetched room context by sender allowlist (#58376)
* fix(matrix): filter fetched room context by sender allowlist

* style(matrix): normalize reply context guard formatting

* fix(matrix): drop raw ids from allowlist context logs
2026-03-31 17:09:03 +01:00
Jacob Tomlinson
6c679e5f04 Gateway: reject mixed trusted-proxy token config (#58371)
* Gateway: reject mixed trusted-proxy token config

Co-authored-by: boy-hack <w8ay@qq.com>

* Gateway: fail closed for loopback trusted-proxy auth

---------

Co-authored-by: boy-hack <w8ay@qq.com>
2026-03-31 17:05:03 +01:00
Peter Steinberger
aab7335236 fix(media): restore whatsapp outbound compatibility 2026-04-01 01:00:27 +09:00
Jacob Tomlinson
78e74d4a64 Plugins: preserve prompt build system prompt precedence (#58375) 2026-03-31 16:52:09 +01:00
Peter Steinberger
1a4c9c3e85 fix: repair extension media ci coverage 2026-03-31 16:47:13 +01:00
Peter Steinberger
7d2b4ed4e1 fix: restore whatsapp runtime seams 2026-03-31 16:47:13 +01:00
Peter Steinberger
6eddd55393 test: accept media loader option expansion 2026-03-31 16:47:13 +01:00
Peter Steinberger
a842e34f15 test: require Claude 4.6 for Anthropic live selection 2026-03-31 16:41:50 +01:00
Peter Steinberger
8f2e1194b7 docs: reorder changelog by user interest 2026-03-31 16:34:45 +01:00
Peter Steinberger
43ef8a5a86 refactor(media): centralize outbound access plumbing 2026-04-01 00:32:53 +09:00
Vincent Koc
c416527df6 fix(whatsapp): restore runtime send and action seam 2026-04-01 00:25:35 +09:00
Peter Steinberger
015ab98591 fix: restore ci status fast path and whatsapp tests 2026-03-31 16:21:55 +01:00
Vincent Koc
2a1db0c0f1 fix(gateway): narrow plugin route runtime scopes (#58167)
* wip(gateway): preserve plugin route scope progress

* test(gateway): cover plugin route runtime scopes

* test(gateway): finish plugin route scope rebase

* fix(gateway): drop scopes from plugin-auth routes
2026-04-01 00:20:49 +09:00
Peter Steinberger
85611f0021 fix: tighten gateway startup plugin loading 2026-04-01 00:20:06 +09:00
Vincent Koc
1ca12ec8bf fix(hooks): rebind hook agent session keys to the target agent (#58225)
* fix(hooks): rebind hook agent session keys

* fix(hooks): preserve scoped hook session keys

* fix(hooks): validate normalized dispatch keys
2026-04-01 00:16:39 +09:00
Peter Steinberger
fc5a2f9293 fix(media): add host media read helper 2026-04-01 00:08:20 +09:00
Peter Steinberger
3bb02d3338 fix(media): align outbound sends with fs read capability 2026-04-01 00:07:50 +09:00
openperf
56b5ba0dcb fix: address security and review feedback
- Fix CWE-209: use static safe message instead of raw provider error text
- Fix CWE-117: sanitize provider/model in logs via sanitizeForLog
- Hide CLI hints from external channels via shouldSurfaceToControlUi
- Move overload cap check before advanceAuthProfile to save setup latency
- Export MAX_LIVE_SWITCH_RETRIES as module-level constant
- Use exact toBe() assertions in tests
- Correct failover decision label to fallback_model
2026-03-31 20:25:09 +05:30
openperf
1fcd179d8c fix(gateway): prevent session death loop on overloaded fallback
- Add MAX_LIVE_SWITCH_RETRIES=2 guard in agent-runner-execution.ts
- Add MAX_OVERLOAD_PROFILE_ROTATIONS=1 cap in run.ts for overloaded errors
- Return kind:final with user-visible error on retry exhaustion
- Escalate to cross-provider fallback instead of exhausting same-provider profiles

Fixes #58348
2026-03-31 20:25:09 +05:30
Peter Steinberger
bf96c67fd1 fix: align skill install security gate 2026-03-31 15:53:29 +01:00
Peter Steinberger
192484ed0a fix: log malformed tool parameters on failure 2026-03-31 15:50:14 +01:00
Peter Steinberger
7dffd8160a test(extensions): use ModelRegistry factory 2026-03-31 23:50:03 +09:00
Peter Steinberger
a1e2d2bf42 test: repair stale task and image mocks 2026-03-31 15:48:00 +01:00
Peter Steinberger
c425ef3e74 build: bump version to 2026.3.31 2026-03-31 15:48:00 +01:00
Peter Steinberger
5e30da3cad fix(exec): restore strict inline-eval allow-always reuse 2026-03-31 23:45:22 +09:00
Vincent Koc
5aac609e08 test(ci): rebalance telegram thread binding timing 2026-03-31 23:42:05 +09:00
Peter Steinberger
ac6f025c43 refactor(approvals): share telegram account binding 2026-03-31 15:39:59 +01:00
Vincent Koc
b3a2734cc9 test(ci): rebalance telegram acp binding timing 2026-03-31 23:38:45 +09:00
Vincent Koc
983891a603 fix(ci): narrow telegram route test seams 2026-03-31 23:37:18 +09:00
Peter Steinberger
461a3a4052 refactor(approvals): share request filter matching 2026-03-31 15:32:49 +01:00
Vincent Koc
7c4bffdecd fix(ci): rebalance telegram dm thread tests 2026-03-31 23:32:15 +09:00
Peter Steinberger
177687ae29 fix: adapt pi model registry calls to constructor API 2026-03-31 15:28:29 +01:00
Peter Steinberger
0d7f1e2c84 feat(security): fail closed on dangerous skill installs 2026-03-31 23:27:20 +09:00
Vincent Koc
98c0c38186 fix(ci): rebalance telegram channel tails 2026-03-31 23:24:16 +09:00
Peter Steinberger
da6e9bb76f refactor: move tasks behind plugin-sdk seam 2026-03-31 15:22:09 +01:00
Peter Steinberger
e1da91791a build: externalize bundled plugin runtime deps 2026-03-31 15:22:08 +01:00
Peter Steinberger
9537094841 test: refresh plugin sdk baseline 2026-03-31 15:22:08 +01:00
Peter Steinberger
c75f4695b7 refactor: move tasks into bundled plugin 2026-03-31 15:22:08 +01:00
Peter Steinberger
584db0aff2 fix(approvals): centralize native request binding 2026-03-31 15:20:47 +01:00
Vincent Koc
2523e25c93 test(ci): rebalance telegram implicit mention timing 2026-03-31 23:17:40 +09:00
Peter Steinberger
0ed7f1fd22 refactor: remove core WhatsApp runtime channel seam 2026-03-31 15:17:13 +01:00
Peter Steinberger
e8cb0b3659 fix: tighten live gateway empty-response skips and outbound harness typing 2026-03-31 15:17:13 +01:00
Peter Steinberger
d90b627e1b build: copy bundled plugin postinstall script into cleanup smoke image 2026-03-31 15:17:13 +01:00
Peter Steinberger
44b9936136 feat(plugins): add dangerous unsafe install override 2026-03-31 23:16:11 +09:00
Peter Steinberger
59866dd253 fix(memory): restore readonly recovery helper seams 2026-03-31 23:14:24 +09:00
Altay
ba4116e6a9 build: comment out pnpm release-age exclude 2026-03-31 23:10:07 +09:00
Altay
9407ac87df build: move pnpm minimum release age to workspace config 2026-03-31 23:10:07 +09:00
Peter Steinberger
8807b017d1 test: harden channel planner lane matching 2026-03-31 23:08:23 +09:00
Peter Steinberger
4fb373466e refactor: simplify memory recovery and test setup 2026-03-31 15:02:11 +01:00
Vincent Koc
6936033e98 test(telegram): stop overriding message-context session mocks 2026-03-31 23:01:21 +09:00
Peter Steinberger
0711cb4a05 fix(hooks): reduce registration log noise 2026-03-31 14:59:22 +01:00
Peter Steinberger
dc0e0b0f68 docs(security): mark shared-secret HTTP auth as designed 2026-03-31 22:58:09 +09:00
Jacob Tomlinson
a4d72a83f0 fix(tlon): preserve explicit empty settings during migration (#58370) 2026-03-31 14:57:03 +01:00
Peter Steinberger
c1ea0ae9c8 build: update deps and align pi sdk usage 2026-03-31 22:56:20 +09:00
Peter Steinberger
cbfeecfab4 fix(gateway): restore shared-secret HTTP tool invoke auth 2026-03-31 22:55:15 +09:00
Jacob Tomlinson
0c83754246 Exec approvals: reject shell init-file script matches (#58369) 2026-03-31 14:53:43 +01:00
Vincent Koc
0ed4f8a72b fix(media): reject oversized image inputs before decode (#58226)
* fix(media): cap oversized image inputs

* chore(changelog): add media input guard note

* fix(media): address input guard review feedback

* fix(media): fail closed on unknown sips dimensions

* fix(media): avoid sips fallback in input guard
2026-03-31 22:52:55 +09:00
Vincent Koc
aaf6077f27 test(telegram): skip session persistence in message-context harness 2026-03-31 22:51:25 +09:00
Vincent Koc
4ee742174f fix(nostr): verify inbound dm signatures before pairing replies (#58236)
* fix(nostr): verify inbound dm signatures before pairing

* fix(nostr): authorize senders before rate limiting

* test(nostr): cover pending auth rate-limit starvation

* fix(nostr): rate limit oversized inbound ciphertext

* fix(nostr): dedupe blocked inbound replays

* fix(nostr): rate limit before auth work
2026-03-31 22:51:22 +09:00
Peter Steinberger
5fc8f6ca8f test: align targeted channel batching expectation 2026-03-31 14:49:04 +01:00
Vincent Koc
29b9310319 fix(scripts): normalize bundled entry paths and planner counts 2026-03-31 22:47:12 +09:00
Vincent Koc
3be08454f4 test(telegram): narrow resolve-media retry imports 2026-03-31 22:45:39 +09:00
Vincent Koc
91115cdf61 test(telegram): stub menu sync in command harness 2026-03-31 22:31:12 +09:00
Vincent Koc
2df86cce1c refactor(telegram): narrow native command reply dispatch seam 2026-03-31 22:28:53 +09:00
Peter Steinberger
5a93344d82 fix: ship bundled runtime support packages 2026-03-31 14:25:32 +01:00
Vincent Koc
5b7443d175 perf(whatsapp): narrow reply chunking imports 2026-03-31 22:25:14 +09:00
Peter Steinberger
e7e383b7cf build: exclude @mariozechner packages from pnpm release age 2026-03-31 22:23:30 +09:00
Vincent Koc
ff36bc314d test(telegram): use shared delivery mock in registry test 2026-03-31 22:18:29 +09:00
Vincent Koc
3f2fb73cfe perf(slack): avoid module resets in outbound adapter test 2026-03-31 22:13:39 +09:00
Frank Yang
dbe6663c34 fix(qqbot): align speech schema and setup validation (#58253)
* fix(qqbot): align speech schema and setup validation

* fix(qqbot): preserve use-env setup flow

* fix(qqbot): reject use-env on named accounts

* fix(qqbot): restore default account schema support
2026-03-31 21:11:45 +08:00
Gustavo Madeira Santana
8dbba7d17c fix(scripts/pr): make cleanup worktree-safe 2026-03-31 09:07:42 -04:00
Gustavo Madeira Santana
27b9665871 chore: clarify test performance guardrail 2026-03-31 09:07:42 -04:00
Vincent Koc
d369c9373b perf(whatsapp): avoid module resets in poll adapter test 2026-03-31 22:06:01 +09:00
Vincent Koc
37099dae3e fix(ci): restore matrix monitor import guards and windows npm exit codes 2026-03-31 22:04:35 +09:00
Vincent Koc
35072c4751 perf(discord): avoid broad send barrel in webhook activity test 2026-03-31 22:02:01 +09:00
Vincent Koc
675b80c4a4 perf(slack): narrow send chunking imports 2026-03-31 21:58:00 +09:00
Gustavo Madeira Santana
4ea1ca4849 Sessions: parse thread suffixes by channel (#58100)
Merged via squash.

Prepared head SHA: 2829b9c5b5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 08:54:16 -04:00
Vincent Koc
11590eb6ce fix(ci): restore dotenv trust boundary and windows npm exit handling 2026-03-31 21:51:17 +09:00
Gustavo Madeira Santana
3ceec929df Matrix: narrow monitor runtime seam 2026-03-31 08:46:53 -04:00
Vincent Koc
7710579a82 perf(telegram): narrow native command import surface 2026-03-31 21:43:16 +09:00
Vincent Koc
b19e28a85e fix(telegram): lazy-load sticker vision model lookup 2026-03-31 21:31:05 +09:00
Vincent Koc
dba96e7507 fix(discord): gate voice ingress by allowlists (#58245)
* fix(discord): gate voice ingress by allowlists

* fix(discord): preserve voice allowlist context

* fix(discord): fetch guild metadata for voice allowlists

* fix(discord): reuse voice speaker context

* fix(discord): preserve cached speaker context

* fix(discord): tighten voice ingress authorization
2026-03-31 21:29:13 +09:00
Vincent Koc
25a3d37970 fix(ci): restore matrix guardrails and windows exec shim 2026-03-31 21:27:43 +09:00
Gustavo Madeira Santana
f8af407c86 build: pin axios to 1.13.6
Pin axios through pnpm overrides and collapse the lockfile to a single
1.13.6 resolution.

This avoids accidental adoption of the compromised axios releases called
out in the ongoing supply chain attack reports while upstream guidance
settles.
2026-03-31 08:27:00 -04:00
Vincent Koc
4d912e0451 fix(exec): block proxy-style env overrides (#58202)
* fix(exec): block proxy-style env overrides

* fix(exec): keep trusted host proxy env inherited

* fix(exec): block git tls override env vars

* fix(skills): block dangerous env override keys
2026-03-31 21:25:36 +09:00
Gustavo Madeira Santana
28bb8c600e Matrix: narrow thread binding runtime seam 2026-03-31 08:12:46 -04:00
Gustavo Madeira Santana
305977571d Matrix: narrow storage and routing imports 2026-03-31 08:12:46 -04:00
Vincent Koc
e6441760d2 test(telegram): normalize message-context timing inputs 2026-03-31 21:10:43 +09:00
Vincent Koc
415e7d941b test(slack): remove slash metadata polling 2026-03-31 21:02:06 +09:00
Vincent Koc
730ba40763 fix(exec): unwrap arch and xcrun dispatch wrappers (#58203)
* fix(exec): unwrap arch and xcrun dispatch wrappers

* fix(infra): scope arch wrapper unwrapping to macos

* fix(exec): scope arch wrapper unwrapping to macos

* fix(infra): validate macos arch wrapper selectors

* test(infra): cover invalid arch name wrappers
2026-03-31 21:00:14 +09:00
Jacob Tomlinson
2ce44ca6a1 fix(plugins): guard marketplace archive downloads (#58267)
* Plugins: guard marketplace archive downloads

* Plugins: harden marketplace download cleanup

* Plugins: bound marketplace archive downloads

* Plugins: harden marketplace archive failures

* Plugins: reject drive-relative marketplace archives

* Plugins: stream marketplace archive downloads
2026-03-31 12:59:42 +01:00
Mariano
607076d164 ClawFlow: add runtime substrate (#58336)
Merged via squash.

Prepared head SHA: 6a6158179e
Reviewed-by: @mbelinky
2026-03-31 13:58:29 +02:00
Vincent Koc
f2d4089ca2 test(discord): remove monitor polling overhead 2026-03-31 20:56:37 +09:00
Vincent Koc
334085fbe9 test(channels): inject telegram reply pipeline for dispatch tests 2026-03-31 20:54:30 +09:00
Vincent Koc
5474796735 docs(security): clarify acpx yolo mode 2026-03-31 20:54:30 +09:00
pgondhi987
d8c68c8d42 fix: migrate Telegram pairing allowFrom to default account only (#58165)
* fix: migrate Telegram pairing allowFrom to default account only

* fix: address PR review feedback

* fix: address PR review feedback
2026-03-31 12:51:38 +01:00
Vincent Koc
62c28c0708 test(discord): isolate ACP binding routing seam 2026-03-31 20:49:31 +09:00
Vincent Koc
b4ac69c652 docs(acp): align approval policy wording 2026-03-31 20:49:31 +09:00
Vincent Koc
cd5179314d fix(acp): use semantic approval classes 2026-03-31 20:49:31 +09:00
Gustavo Madeira Santana
d077faab1a Matrix: narrow monitor runtime imports 2026-03-31 07:29:47 -04:00
Gustavo Madeira Santana
2bdf2fbf14 Matrix: trim storage test import churn 2026-03-31 07:29:47 -04:00
Vincent Koc
225dfe0094 fix(ci): stabilize planner executor fallback tests 2026-03-31 20:26:28 +09:00
Gustavo Madeira Santana
8c0245f57b fix(matrix): tighten DM invite promotion state (#58099)
Merged via squash.

Prepared head SHA: 6638d4b505
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 07:09:18 -04:00
Vincent Koc
1243e2c0b6 fix(telegram): keep test harness CJS-safe 2026-03-31 20:04:21 +09:00
Vincent Koc
e704323ff3 fix(media): drop auth headers on cross-origin redirects (#58224)
* fix(media): drop auth headers on cross-origin redirects

* chore(changelog): sync unreleased context

* fix(media): keep fetch-guard redirect helper working
2026-03-31 19:57:42 +09:00
Vincent Koc
3d5af14984 fix(agents): reject escaping symlinks in ssh sandbox uploads (#58220)
* fix(agents): reject escaping ssh sandbox upload symlinks

* fix(agents): allow safe ssh upload symlink aliases

* test(ssh): keep upload stdin open in fake ssh

* Update CHANGELOG.md
2026-03-31 19:56:45 +09:00
FMLS
44caf1ee3d fix(browser): prevent cross-origin images from disappearing in CDP screenshots (#54358)
fromSurface: true + captureBeyondViewport: true triggers a Chromium compositor
bug where cross-origin image textures are lost when extending the capture
surface. Switch to fromSurface: false to use the software rendering path.

For full-page captures, temporarily expand the viewport via
Emulation.setDeviceMetricsOverride, preserving the current mobile/DPR/screen
state during capture and restoring it afterward so pre-existing device
emulation is not lost.

Made-with: Cursor

Co-authored-by: hakunaliu <hakunaliu@tencent.com>
2026-03-31 18:55:25 +08:00
Vincent Koc
57700d716f fix(config): redact Nostr privateKey in config views (#58177)
* wip(config): preserve nostr redaction progress

* fix(config): add private key redaction fallback

* fix(config): align nostr privateKey secret input handling

* fix(config): require resolved nostr private keys
2026-03-31 19:55:03 +09:00
Vincent Koc
efe9183f9d fix(voice-call): pin plivo callback origins (#58238) 2026-03-31 19:50:35 +09:00
Vincent Koc
cf3ae2612b fix(ci): reduce slow channel test skew 2026-03-31 19:49:40 +09:00
Vincent Koc
da7f016db6 fix(doctor): align qmd probe cwd with runtime 2026-03-31 19:49:40 +09:00
Vincent Koc
6b3f99a11f fix(gateway): enforce trusted-proxy HTTP origin checks (#58229)
* fix(gateway): enforce trusted-proxy HTTP origin checks

* Update CHANGELOG.md
2026-03-31 19:49:26 +09:00
Vincent Koc
9abcfdadf5 fix(voice-call): reject oversized pre-start media frames (#58241)
* fix(voice-call): reject oversized pre-start frames

* fix(voice-call): avoid normalizing oversized frames

* chore(changelog): remove stray spacing

* fix(voice-call): remove dead inbound size guard
2026-03-31 19:47:10 +09:00
Vincent Koc
9bc1f896c8 fix(pairing): scope pending request caps per account (#58239)
* fix(pairing): scope pending pairing caps per account

* fix(pairing): count legacy default-account requests
2026-03-31 19:45:45 +09:00
Vincent Koc
f45e5a6569 fix(feishu): filter fetched group thread context (#58237)
* fix(feishu): filter fetched group thread context

* fix(feishu): preserve filtered thread bootstrap
2026-03-31 19:43:54 +09:00
Vincent Koc
2194587d70 fix(tlon): cap inbound image downloads (#58223) 2026-03-31 19:40:15 +09:00
Vincent Koc
9023a0436c fix(exec): unwrap transparent approval wrappers (#58215)
* fix(exec): unwrap transparent approval wrappers

* fix(exec): normalize sandbox-exec -D wrapper parsing
2026-03-31 19:38:34 +09:00
Vincent Koc
eb8de6715f fix(exec): block risky host env overrides (#58209)
* fix(exec): block risky host env overrides

* fix(exec): block GOPRIVATE host env overrides
2026-03-31 19:37:43 +09:00
Vincent Koc
57c47d8c7f fix(line): bound preverify webhook concurrency (#58199)
* fix(line): bound preverify webhook concurrency

* test(line): cover preauth release timing

* fix(line): release webhook preauth slots earlier
2026-03-31 19:34:25 +09:00
Vincent Koc
4d038bb242 fix(zalo): scope webhook replay dedupe per target (#58196) 2026-03-31 19:33:57 +09:00
Vincent Koc
57fccca2dc fix(exec): keep awk and sed out of safeBins fast path (#58175)
* wip(exec): preserve safe-bin semantics progress

* test(exec): cover safe-bin semantic variants

* fix(exec): address safe-bin review follow-up
2026-03-31 19:29:53 +09:00
Vincent Koc
330a9f98cb fix(config): block workspace bundled-root dotenv overrides (#58170)
* wip(config): preserve bundled hooks root progress

* test(config): cover bundled trust-root dotenv blocking
2026-03-31 19:25:12 +09:00
Vincent Koc
b9f857708c wip(config): preserve bundled plugins root progress (#58168) 2026-03-31 19:23:11 +09:00
Jacob Tomlinson
781775ec08 Media: secure image temp dirs (#58270) 2026-03-31 11:12:47 +01:00
Ayaan Zaidi
6be0c7ef09 fix(android): drop bootstrap auth after manual endpoint changes 2026-03-31 15:32:36 +05:30
Jacob Tomlinson
7bd2761b92 Exec approvals: detect command carriers in strict inline eval (#57842)
* Exec approvals: detect command carriers in strict inline eval

* Exec approvals: cover carrier option edge cases

* Exec approvals: cover make and find carriers

* Exec approvals: catch attached eval flags

* Exec approvals: keep sed -E out of inline eval

* Exec approvals: treat sed in-place flags as optional
2026-03-31 10:58:17 +01:00
Ayaan Zaidi
cbc75f13b2 test(android): cover node-only onboarding state 2026-03-31 15:21:39 +05:30
Ayaan Zaidi
132208c01f fix(android): require node connection before onboarding finish 2026-03-31 15:21:39 +05:30
Ayaan Zaidi
c1269eddb8 fix(android): preserve bootstrap auth for manual reconnect 2026-03-31 15:21:39 +05:30
Jacob Tomlinson
eb84d91a80 UI: build delete confirm popover without HTML strings (#58269)
* UI: build delete confirm popover safely

* UI: share delete confirm storage key
2026-03-31 10:42:07 +01:00
Jacob Tomlinson
df0e136bc7 Canvas Host: build default status with DOM nodes (#58266) 2026-03-31 10:29:28 +01:00
Vincent Koc
e95f786aa2 fix(dev): sync run-node test types 2026-03-31 18:04:22 +09:00
Jacob Tomlinson
a23c33a681 macOS: use MagicDNS for wide-area gateway discovery (#57833)
* macOS: use MagicDNS for wide-area gateway discovery

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* macOS: tighten wide-area discovery review follow-ups

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
2026-03-31 10:04:11 +01:00
Vincent Koc
f288ff3f9f fix(tests): stabilize cron and blocked-flow assertions 2026-03-31 17:58:41 +09:00
Vincent Koc
cd8d0881ed fix(dev): classify dirty-tree watch invalidations 2026-03-31 17:54:05 +09:00
Vincent Koc
622bdfdad1 docs(memory): clarify qmd symlink traversal limits 2026-03-31 17:54:00 +09:00
Vincent Koc
2befbc5e60 fix(matrix): restore local helper seams 2026-03-31 17:42:37 +09:00
Vincent Koc
4e2a072b5b fix(memory): add qmd degraded status changelog 2026-03-31 17:37:12 +09:00
Vincent Koc
d27165f5de fix(tasks): allow new task read-model importers 2026-03-31 17:35:39 +09:00
Vincent Koc
3a5042b6cc fix(memory): surface qmd degraded vector status 2026-03-31 17:35:36 +09:00
Vincent Koc
af37fca556 fix(qqbot): mark npm-publishable package public 2026-03-31 17:33:48 +09:00
Vincent Koc
0b76d85509 fix(qqbot): declare silk-wasm codec types 2026-03-31 17:30:22 +09:00
Vincent Koc
72d1725fcf fix(tasks): preserve nullable flow patch clears 2026-03-31 17:24:54 +09:00
zsxsoft
d15d7d0962 fix(scripts/pr): shell-escape env file values to prevent command injection via branch names 2026-03-31 17:24:19 +09:00
pgondhi987
f865a5455e fix(media): drop sensitive headers on cross-origin redirects [AI] (#58156) 2026-03-31 09:22:11 +01:00
Vincent Koc
549169f746 fix(docs): format memory config reference 2026-03-31 17:18:21 +09:00
Vincent Koc
d2dcd6fca6 fix(memory): stagger qmd embed maintenance across agents (#58180)
* fix(memory): stagger qmd embed maintenance across agents

* fix(memory): keep qmd embed serialization in-process

* fix(memory): extend qmd embed lock wait budget
2026-03-31 17:17:20 +09:00
Sliverp
bf6f506dfa Feature/add qq channel (#52986)
* feat: add QQ Bot channel extension

* fix(qqbot): add setupWizard to runtime plugin for onboard re-entry

* fix: fix review

* fix: fix review

* chore: sync lockfile and config-docs baseline for qqbot extension

* refactor: 移除图床服务器相关代码

* fix

* docs: 新增 QQ Bot 插件文档并修正链接路径

* refactor: remove credential backup functionality and update setup logic

- Deleted the credential backup module to streamline the codebase.
- Updated the setup surface to handle client secrets more robustly, allowing for configured secret inputs.
- Simplified slash commands by removing unused hot upgrade compatibility checks and related functions.
- Adjusted types to use SecretInput for client secrets in QQBot configuration.
- Modified bundled plugin metadata to allow additional properties in the config schema.

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: remove qqbot-media and qqbot-remind skills, add tests for config and setup

- Deleted the qqbot-media and qqbot-remind skills documentation files.
- Added unit tests for qqbot configuration and setup processes, ensuring proper handling of SecretRef-backed credentials and account configurations.
- Implemented tests for local media path remapping, verifying correct resolution of media file paths.
- Removed obsolete channel and remind tools, streamlining the codebase.

* feat: 更新 QQBot 配置模式,添加音频格式和账户定义

* feat: 添加 QQBot 频道管理和定时提醒技能,更新媒体路径解析功能

* fix

* feat: 添加 /bot-upgrade 指令以查看 QQBot 插件升级指引

* feat: update reminder and qq channel skills

* feat: 更新remind工具投递目标地址格式

* feat: Refactor QQBot payload handling and improve code documentation

- Simplified and clarified the structure of payload interfaces for Cron reminders and media messages.
- Enhanced the parsing function to provide clearer error messages and improved validation.
- Updated platform utility functions for better cross-platform compatibility and clearer documentation.
- Improved text parsing utilities for better readability and consistency in emoji representation.
- Optimized upload cache management with clearer comments and reduced redundancy.
- Integrated QQBot plugin into the bundled channel plugins and updated metadata for installation.

* OK apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

> openclaw@2026.3.26 check:bundled-channel-config-metadata /Users/yuehuali/code/PR/openclaw
> node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check

[bundled-channel-config-metadata] stale generated output at src/config/bundled-channel-config-metadata.generated.ts
 ELIFECYCLE  Command failed with exit code 1.
 ELIFECYCLE  Command failed with exit code 1.

* feat: 添加 QQBot 渠道配置及相关账户设置

* fix(qqbot): resolve 14 high-priority bugs from PR #52986 review

DM routing (7 fixes):
- #1: DM slash-command replies use sendDmMessage(guildId) instead of sendC2CMessage(senderId)
- #2: DM qualifiedTarget uses qqbot:dm:${guildId} instead of qqbot:c2c:${senderId}
- #3: sendTextChunks adds DM branch
- #4: sendMarkdownReply adds DM branch for text and Base64 images
- #5: parseAndSendMediaTags maps DM to targetType:dm + guildId
- #6: sendTextToTarget DM branch uses sendDmMessage; MessageTarget adds guildId field
- #7: handleImage/Audio/Video/FilePayload add DM branches

Other high-priority fixes:
- #8: Fix sendC2CVoiceMessage/sendGroupVoiceMessage parameter misalignment
- #9: broadcastMessage uses groupOpenid instead of member_openid for group users
- #10: Unify KnownUser storage - proactive.ts delegates to known-users.ts
- #11: Remove invalid recordKnownUser calls for guild/DM users
- #12: sendGroupMessage uses sendAndNotify to trigger onMessageSent hook
- #13: sendPhoto channel unsupported returns error field
- #14: sendTextAfterMedia adds channel and dm branches

Type fixes:
- DeliverEventContext adds guildId field
- MediaTargetContext.targetType adds dm variant
- sendPlainTextReply imgMediaTarget adds DM branch

* fix(qqbot): resolve 2 blockers + 7 medium-priority bugs from PR #52986 review

Blocker-1: Remove unused dmPolicy config knob
- dmPolicy was declared in schema/types/plugin.json but never consumed at runtime
- Removed from config-schema.ts, types.ts, and openclaw.plugin.json
- allowFrom remains active (already wired into framework command-auth)

Blocker-2: Gate sensitive slash commands with allowFrom authorization
- SlashCommand interface adds requireAuth?: boolean
- SlashCommandContext adds commandAuthorized: boolean
- /bot-logs set to requireAuth: true (reads local log files)
- matchSlashCommand rejects unauthorized senders for requireAuth commands
- trySlashCommandOrEnqueue computes commandAuthorized from allowFrom config

Medium-priority fixes:
- #15: Strip non-HTTP/non-local markdown image tags to prevent path leakage
- #16: applyQQBotAccountConfig clears clientSecret when setting clientSecretFile and vice versa
- #17: getAdminMarkerFile sanitizes accountId to prevent path traversal
- #18: URGENT_COMMANDS uses exact match instead of startsWith prefix match
- #19: isCronExpression validates each token starts with a cron-valid character
- #20: --token format validation rejects malformed input without colon separator
- #21: resolveDefaultQQBotAccountId checks QQBOT_APP_ID environment variable

* test(qqbot): add focused tests for slash command authorization path

- Unauthorized sender rejected for /bot-logs (requireAuth: true)
- Authorized sender allowed for /bot-logs
- Non-requireAuth commands (/bot-ping, /bot-help, /bot-version) work for all senders
- Unknown slash commands return null (passthrough)
- Non-slash messages return null
- Usage query (/bot-logs ?) also gated by auth check

* fix(qqbot): align global TTS fallback with framework config resolution

- Extract isGlobalTTSAvailable to utils/audio-convert.ts, mirroring core
  resolveTtsConfig logic: check auto !== 'off', fall back to legacy
  enabled boolean, default to off when neither is set.
- Add pre-check in reply-dispatcher before calling globalTextToSpeech to
  avoid unnecessary TTS calls and noisy error logs when TTS is not
  configured.
- Remove inline as any casts; use OpenClawConfig type throughout.
- Refactor handleAudioPayload into flat early-return structure with
  unified send path (plugin TTS → global fallback → send).

* fix(qqbot): break ESM circular dependency causing multi-account startup crash

The bundled gateway chunk had a circular static import on the channel
chunk (gateway -> outbound-deliver -> channel, while channel dynamically
imports gateway). When two accounts start concurrently via Promise.all,
the first dynamic import triggers module graph evaluation; the circular
reference causes api exports (including runDiagnostics) to resolve as
undefined before the module finishes evaluating.

Fix: extract chunkText and TEXT_CHUNK_LIMIT from channel.ts into a new
text-utils.ts leaf module. outbound-deliver.ts now imports from
text-utils.ts, breaking the cycle. channel.ts re-exports for backward
compatibility.

* fix(qqbot): serialize gateway module import to prevent multi-account startup race

When multiple accounts start concurrently via Promise.all, each calls
await import('./gateway.js') independently. Due to ESM circular
dependencies in the bundled output, the first import can resolve
transitive exports as undefined before module evaluation completes.

Fix: cache the dynamic import promise in a module-level variable so all
concurrent startAccount calls share the same import, ensuring the
gateway module is fully evaluated before any account uses it.

* refactor(qqbot): remove startup greeting logic

Remove getStartupGreetingPlan and related startup greeting delivery:
- Delete startup-greeting.ts (greeting plan, marker persistence)
- Delete admin-resolver.ts (admin resolution, greeting dispatch)
- Remove startup greeting calls from gateway READY/RESUMED handlers
- Remove isFirstReadyGlobal flag and adminCtx

* fix(qqbot): skip octal escape decoding for Windows local paths

Windows paths like C:\Users\1\file.txt contain backslash-digit sequences
that were incorrectly matched as octal escape sequences and decoded,
corrupting the file path. Detect Windows local paths (drive letter or UNC
prefix) and skip the octal decoding step for them.

* fix bot issue

* feat: 支持 TTS 自动开关并清理配置中的 clientSecretFile

* docs: 添加 QQBot 配置和消息处理的设计说明

* rebase

* fix(qqbot): align slash-command auth with shared command-auth model

Route requireAuth:true slash commands (e.g. /bot-logs) through the
framework's api.registerCommand() so resolveCommandAuthorization()
applies commands.allowFrom.qqbot precedence and qqbot: prefix
normalization before any handler runs.

- slash-commands.ts: registerCommand() now auto-routes by requireAuth
  into two maps (commands / frameworkCommands); getFrameworkCommands()
  exports the auth-required set for framework registration; bot-help
  lists both maps
- index.ts: registerFull() iterates getFrameworkCommands() and calls
  api.registerCommand() for each; handler derives msgType from ctx.from,
  sends file attachments via sendDocument, supports multi-account via
  ctx.accountId
- gateway.ts (inbound): replace raw allowFrom string comparison with
  qqbotPlugin.config.formatAllowFrom() to strip qqbot: prefix and
  uppercase before matching event.senderId
- gateway.ts (pre-dispatch): remove stale auth computation; commandAuthorized
  is true (requireAuth:true commands never reach matchSlashCommand)
- command-auth.test.ts: add regression tests for qqbot: prefix
  normalization in the inbound commandAuthorized computation
- slash-commands.test.ts: update /bot-logs tests to expect null
  (command routed to framework, not in local registry)

* rebase and solve conflict

* fix(qqbot): preserve mixed env setup credentials

---------

Co-authored-by: yuehuali <yuehuali@tencent.com>
Co-authored-by: walli <walli@tencent.com>
Co-authored-by: WideLee <limkuan24@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-31 16:13:16 +08:00
Mariano
f86e5c0a08 ClawFlow: add linear flow control surface (#58227)
* ClawFlow: add linear flow control surface

* Flows: clear blocked metadata on resume
2026-03-31 10:08:50 +02:00
Vincent Koc
ab4ddff7f1 feat(memory): add per-agent QMD extra collections for cross-agent session search (#58211)
* feat(memory): add per-agent qmd extra collections

* test(config): cover qmd extra collections schema outputs

* docs(config): refresh qmd extra collections baseline

* docs(config): regenerate qmd extra collections baselines

* docs(config): clarify qmd extra collection naming
2026-03-31 17:08:18 +09:00
Vincent Koc
5707038e6c fix(memory): preserve qmd query semantics and collection recovery (#58183)
* fix(memory): preserve qmd search queries and repair collection rebuilds

* fix(qmd): cover null-byte rebuild cycle
2026-03-31 17:07:35 +09:00
Vincent Koc
f96e150450 fix(doctor): suppress qmd session orphan cleanup (#58182) 2026-03-31 17:06:24 +09:00
Vincent Koc
075645f5cb fix(memory): use explicit qmd snippet line metadata (#58181)
* fix(memory): preserve qmd snippet line metadata

* Memory/QMD: preserve snippet span with partial line metadata
2026-03-31 17:05:53 +09:00
Vincent Koc
fcc2488579 fix(tasks): align flow patch optionals 2026-03-31 17:04:20 +09:00
Vincent Koc
34ae78bfee fix(tests): reduce matrix extension import churn 2026-03-31 16:59:38 +09:00
Vincent Koc
dfc124c772 fix(matrix): reduce extension test import churn 2026-03-31 16:54:04 +09:00
Peter Steinberger
0633406ff6 fix(gateway): restore compat HTTP operator auth 2026-03-31 16:49:30 +09:00
Vincent Koc
6eb42593fa fix(slack): restore plugin approval auth 2026-03-31 16:45:46 +09:00
Josh Avant
788f56f30f Secrets: hard-fail unsupported SecretRef policy and fix gateway restart token drift (#58141)
* Secrets: enforce C2 SecretRef policy and drift resolution

* Tests: add gateway auth startup/reload SecretRef runtime coverage

* Docs: sync C2 SecretRef policy and coverage matrix

* Config: hard-fail parent SecretRef policy writes

* Secrets: centralize unsupported SecretRef policy metadata

* Daemon: test service-env precedence for token drift refs

* Config: keep per-ref dry-run resolvability errors

* Docs: clarify config-set parent-object policy checks

* Gateway: fix drift fallback and schema-key filtering

* Gateway: align drift fallback with credential planner

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-31 02:37:31 -05:00
Mariano
8d942000c9 Tasks: add blocked flow retry state (#58204) 2026-03-31 09:33:26 +02:00
sudie-codes
4e67e7c02c msteams: add member-info action via Graph API (#57528) 2026-03-31 02:24:33 -05:00
Vincent Koc
5ec362fe0b feat(slack): add native exec approvals (#58155)
* feat(slack): add native exec approvals

* feat(slack): wire native exec approvals

* Update CHANGELOG.md

* fix(slack): gate native approvals by request filters

* fix(slack): keep local approval prompt path
2026-03-31 16:20:57 +09:00
Vincent Koc
2feb83babb fix(ci): shard fast extension checks 2026-03-31 15:58:50 +09:00
Vincent Koc
a6046c94f7 fix(ci): speed up fast extension scheduling 2026-03-31 15:52:40 +09:00
James L. Cowan Jr.
3bed73dc36 fix(config): migrate removed telegram groupMentionsOnly key (#55336)
Merged via squash.

Prepared head SHA: 23731e27bf
Co-authored-by: jameslcowan <112015792+jameslcowan@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-30 23:11:44 -07:00
Vincent Koc
8dfbcaa200 fix(ci): default local low-memory checks 2026-03-31 15:05:04 +09:00
Josh Lehman
3a87783632 test: avoid extra plugin-sdk guardrail analysis 2026-03-30 22:51:18 -07:00
Vincent Koc
8ef9e7f159 docs: add Related sections to install and help pages
- install/docker.md: link to podman, clawdock, updating, config
- install/node.md: link to overview, updating, getting-started
- install/updating.md: link to overview, doctor, migrating
- help/troubleshooting.md: link to FAQ, gateway/channel/automation troubleshooting, doctor
2026-03-31 14:38:46 +09:00
Vincent Koc
5ee054e9db docs: merge network-model stub into network hub, improve bridge deprecation
- network.md: add Core model prose (loopback-first, canvas host, remote access)
  from the 22-line network-model.md stub
- network-model.md: add redirect note pointing to /network#core-model
- bridge-protocol.md: replace scattered deprecation notes with prominent
  <Warning> callout at the top
2026-03-31 14:37:43 +09:00
Vincent Koc
b970187379 docs: fix oxfmt formatting in remote.md and THREAT-MODEL-ATLAS.md 2026-03-31 14:36:49 +09:00
Vincent Koc
9f0845137a docs: add Related sections to plugin and web interface pages
- building-plugins.md, manifest.md: link to architecture, SDK, channel/provider plugins
- control-ui.md, tui.md: link to sibling web interfaces and CLI
2026-03-31 14:34:56 +09:00
Vincent Koc
74830c7bac docs: add Related sections to 6 major tool pages
Add cross-linking Related sections to tool pages that were dead ends:
- exec, exec-approvals, browser, pdf, skills, lobster

Each page now links to 2-4 related topics for navigation continuity.
2026-03-31 14:34:56 +09:00
Vincent Koc
ff1ae5df22 docs: add 8 missing doctor checks and --generate-gateway-token flag 2026-03-31 14:34:56 +09:00
Vincent Koc
641a6880cf docs: add Related sections to 10 concept pages
Add cross-linking Related sections to concept pages that were dead ends:
- model-providers, models, context, context-engine, agent-workspace,
  architecture, messages, streaming, compaction, oauth

Each page now links to 3-4 related topics for navigation continuity.
2026-03-31 14:34:56 +09:00
Vincent Koc
1bf8fb26f4 docs: fix config examples -- perSession deprecation and dmScope guidance
- Replace perSession: true with scope: "session" (preferred syntax)
- Add dmScope: "per-channel-peer" to expanded example for multi-user safety
2026-03-31 14:34:56 +09:00
Vincent Koc
4ab7947ec0 docs: merge remote-gateway-readme content into remote.md 2026-03-31 14:34:56 +09:00
Ayaan Zaidi
3059eadca2 test: fix provider runtime mocks and test planner load shedding 2026-03-31 11:04:28 +05:30
Vincent Koc
aebdb8f8cf fix(lint): scope oxlint type-aware tsconfig 2026-03-31 14:28:41 +09:00
Vincent Koc
637f15375b docs: fix Gateway & Ops audit findings (7 pages)
- cli-backends.md: remove duplicate modelAliases key
- discovery.md: add missing transport=gateway and displayName TXT keys
- authentication.md: retitle to "Authentication (Model Providers)", add
  disambiguation Note pointing to gateway connection auth docs
- health.md: expand frontmatter scope, add --probe flag and response shape docs
- gateway-lock.md: remove stale hardcoded date, add Related section
- troubleshooting.md: fix wrong auth cross-link (model auth -> gateway config)
- logging.md: add Related section linking to gateway logging internals
2026-03-31 14:24:19 +09:00
Vincent Koc
6c6792446b docs: fix THREAT-MODEL-ATLAS pairing TTLs and invalid file paths 2026-03-31 14:24:19 +09:00
Vincent Koc
d352bd050a docs: fix tools-invoke default deny list (was missing 8 of 13 entries) 2026-03-31 14:24:19 +09:00
Vincent Koc
ab8d999917 docs: fix sandbox scope default (session -> agent per resolveSandboxScope) 2026-03-31 14:24:19 +09:00
Ayaan Zaidi
e42330eff7 fix: remove duplicate sandbox browser start branch 2026-03-31 10:34:09 +05:30
Ayaan Zaidi
aeee17a689 fix(acp): preserve Telegram topic-bound conversation ids 2026-03-31 10:31:01 +05:30
Josh Avant
81b777c768 fix(config): harden SecretRef round-trip handling in Control UI and RPC writes (#58044)
* Config: harden SecretRef round-trip handling

* Gateway: test SecretRef preflight on config writes

* Agents: align skill loader with upstream Skill type

* Docs: align SecretRef write semantics with Control UI and RPC behavior

* Config: add UI and gateway regression evidence for SecretRef hardening

* Config: add token SecretRef restore regression and skill sourceInfo compat

* UI: scope structured-value lockout to SecretRef fields

* Agents: remove out-of-scope skill loader compat edits

* UI: reduce app-render churn to rawAvailable-only changes

* Gateway: scope SecretRef preflight to submitted config

* Docs: clarify config write SecretRef preflight scope

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-30 23:55:03 -05:00
Gabriel M.
f7ced438f7 fix: restore Telegram forum-topic routing (#56060) (thanks @one27001)
* feat(telegram): add child thread-binding placement via createForumTopic

Enable ACP subagent spawn on Telegram by adding "child" placement
support to the thread-bindings adapter. When a child binding is
requested, the adapter creates a new forum topic via the Telegram
Bot API and binds the subagent session to it using the canonical
chatId:topic:topicId conversation ID format.

When the ACP spawn context provides only a topic ID (not a full
group chat ID), the adapter resolves the group from the configured
Telegram groups in openclaw.json.

This mirrors the Discord adapter's child placement behavior
(thread creation + session binding) and unblocks the orchestrator
pattern on Telegram forum-enabled groups.

Closes #5737
Ref #23414

* fix(telegram): return null with warning instead of silent group fallback for bare topic IDs in child bind

* telegram: fix ACP child thread spawn with group chat ID from agentGroupId

* telegram: scope agentGroupId substitution to telegram channel only

* Telegram: fix forum topic replies routing to root chat instead of topic thread

* fix: clean up dead guard in child bind + add explicit threadId override test

- Simplify bare-topic-ID guards in thread-bindings.ts: split into
  separate !chatId and !chatId.startsWith("-") checks, removing
  unreachable second condition
- Add regression test confirming explicit turnSourceThreadId overrides
  session lastThreadId on same channel

* fix: guard threadId fallback against shared-session race

Codex review P1: when turnSourceTo differs from the session's stored
to, the session threadId may belong to a different chat/topic. Only
fall back to context.threadId when the destination also matches.

* fix(telegram): enable ACP spawn from forum topics without thread binding

extractExplicitGroupId returned topic-qualified IDs (-100...:topic:1264)
instead of bare group chat IDs, breaking agentGroupId resolution.
agentGroupId was also never wired in the inline actions path.

For Telegram forum topics, skip thread binding entirely — the delivery
plan already routes correctly via requester origin (to + threadId).
Creating new forum topics per child session is unnecessary; output goes
back to the same topic the user asked from.

* fix(acp): bind Telegram forum sessions to current topic

* fix: restore Telegram forum-topic routing (#56060) (thanks @one27001)

---------

Co-authored-by: openclaw <mgabrie.dev@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:18:09 +05:30
Neerav Makwana
54c69414ad fix: normalize xai tool result image replay (#58017) (thanks @neeravmakwana)
* fix(xai): normalize image tool results for responses

* fix(xai): handle reviewed tool result payload cases

* fix: normalize xai tool result image replay (#58017) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:10:51 +05:30
Neerav Makwana
763d5cea44 fix: hydrate WhatsApp participating groups on connect (#58007) (thanks @neeravmakwana)
* Web: hydrate participating groups on connect

* Web: avoid blocking inbox listeners during group hydration
2026-03-31 10:09:18 +05:30
fuller-stack-dev
235908c30e fix: support multi-kind plugins for dual slot ownership (#57507) (thanks @fuller-stack-dev)
* feat(plugins): support multi-kind plugins for dual slot ownership

* fix: address review feedback on multi-kind plugin support

- Use sorted normalizeKinds() for kind-mismatch comparison in loader.ts
  (fixes order-sensitive JSON.stringify for arrays)
- Derive slot-to-kind reverse mapping from SLOT_BY_KIND in slots.ts
  (removes hardcoded ternary that would break for future slot types)
- Use shared hasKind() helper in config-state.ts instead of inline logic

* fix: don't disable dual-kind plugin that still owns another slot

When a new plugin takes over one slot, a dual-kind plugin that still
owns the other slot must not be disabled — otherwise context engine
resolution fails at runtime.

* fix: exempt dual-kind plugins from memory slot disablement

A plugin with kind: ["memory", "context-engine"] must stay enabled even
when it loses the memory slot, so its context engine role can still load.

* fix: address remaining review feedback

- Pass manifest kind (not hardcoded "memory") in early memory gating
- Extract kindsEqual() helper for DRY kind comparison in loader.ts
- Narrow slotKeyForPluginKind back to single PluginKind with JSDoc
- Reject empty array in parsePluginKind
- Add kindsEqual tests

* fix: use toSorted() instead of sort() per lint rules

* plugins: include default slot ownership in disable checks and gate dual-kind memory registration
2026-03-31 10:06:48 +05:30
issaba1
10ac6ead6b fix: complete cron isolated model-switch retry (#57972) (thanks @issaba1)
* fix: handle LiveSessionModelSwitchError in cron isolated sessions

The main agent runner catches LiveSessionModelSwitchError and retries
with the requested model, but cron isolated sessions hit this error
and fail immediately. This extends the retry to cover cron execution.

When a cron job with `sessionTarget: 'isolated'` specifies a `model`
different from the agent's primary, the embedded runner throws
LiveSessionModelSwitchError (because the session initialized with the
wrong model). The fix wraps the initial runPrompt call in a retry loop
that catches this error, updates provider/model state, and re-runs —
mirroring the existing retry logic in agent-runner-execution.ts.

Fixes #57206

* fix: carry auth profile through cron model retry

* fix: complete cron isolated model-switch retry (#57972) (thanks @issaba1)

---------

Co-authored-by: Isaac Saba <isaacsaba@Isaacs-Mac-mini.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:03:37 +05:30
Neerav Makwana
7516b423eb fix(sandbox): relabel managed workspace mounts for SELinux (#58025) 2026-03-31 00:30:34 -04:00
ToToKr
e89bd883d8 fix: allow Telegram RFC2544 media downloads (#57624) (thanks @MoerAI)
* fix(telegram): allow RFC 2544 benchmark IPs in media download SSRF policy (#57452)

Telegram CDN file servers may resolve to IPs in the RFC 2544 benchmark range (198.18.0.0/15). The SSRF policy blocked these downloads while Discord and Slack correctly allowed them. Set allowRfc2544BenchmarkRange to true to match other channel plugins.

* fix: note Telegram media RFC2544 CDN downloads (#57624) (thanks @MoerAI)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 09:53:31 +05:30
Ayaan Zaidi
9d9ee0f313 fix(security): restore strict SSRF pinning 2026-03-31 09:41:19 +05:30
Gustavo Madeira Santana
28ede9a23e Matrix: isolate verification events hotspot 2026-03-31 00:00:25 -04:00
Gustavo Madeira Santana
1346e6668e Matrix: trim file sync store imports 2026-03-31 00:00:25 -04:00
Gustavo Madeira Santana
57003ffddf Matrix: narrow client auth imports 2026-03-31 00:00:25 -04:00
Josh Avant
44674525f2 feat(tts): add structured provider diagnostics and fallback attempt analytics (#57954)
* feat(tts): add structured fallback diagnostics and attempt analytics

* docs(tts): document attempt-detail and provider error diagnostics

* TTS: harden fallback loops and share error helpers

* TTS: bound provider error-body reads

* tts: add double-prefix regression test and clean baseline drift

* tests(tts): satisfy error narrowing in double-prefix regression

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-30 22:55:28 -05:00
Gustavo Madeira Santana
329d4bf1a8 Matrix: trim sdk test import churn 2026-03-30 23:25:39 -04:00
Kiryl Kavalenka
082778df1a fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004)
* fix(infra/net): route through env proxy in STRICT mode while preserving DNS pinning

When HTTP_PROXY/HTTPS_PROXY env vars are configured, the SSRF guard's
pinned dispatcher connects directly to the DNS-resolved IP, bypassing the
proxy. This fails in environments where direct outbound connections are
blocked (OpenShell sandboxes, Docker containers, corporate networks).

Use `createPinnedDispatcher` with `mode: "env-proxy"` when
`hasEnvHttpProxyConfigured()` returns true. This preserves DNS-pinning
(the resolved IP is threaded into the connect option via
`EnvHttpProxyAgent`) while routing through the proxy.

- Uses `hasEnvHttpProxyConfigured()` (not `hasProxyEnvConfigured()`) to
  avoid the ALL_PROXY edge case where EnvHttpProxyAgent ignores ALL_PROXY
- Preserves STRICT mode's anti-DNS-rebinding guarantee
- TRUSTED_ENV_PROXY remains the explicit opt-in for unpinned proxy routing
- No change when proxy env vars are not set

Fixes #47598, #49948, #32947, #46306
Related: #45248

* test(infra): stabilize fetch guard proxy assertions

* fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004)

---------

Co-authored-by: Kiryl Kavalenka <kiryl.kavalenka@whiparound.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 08:40:45 +05:30
Neerav Makwana
e394262bd8 Agents: fix subagent model precedence 2026-03-31 08:38:24 +05:30
BUGKillerKing
d4cccda570 fix: add requireAgentId to block sessions_spawn without explicit agen… (#29380)
* fix: add requireAgentId to block sessions_spawn without explicit agentId (#29368)

* Config: regenerate base schema for requireAgentId

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: 周鹤0668001310 <zhou.he3@xydigit.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-30 23:06:59 -04:00
Josh Avant
c918ab4faf fix(tts): restore 3.28 schema compatibility and fallback observability (#57953)
* fix(tts): restore legacy config compatibility and fallback observability

* fix(tts): surface fallback attempts in status and telephony

* test(tts): cover /tts audio to /tts status fallback flow

* docs(tts): align migration and fallback observability guidance

* TTS: redact fallback logs and scope legacy plugin migration

* Infra: dedupe UV_EXTRA_INDEX_URL in host env policy

* Docs: scope doctor TTS migration to voice-call

* voice-call: restore strict known TTS provider validation
2026-03-30 22:05:03 -05:00
Teconomix
697dddbeb6 feat(matrix): thread-isolated sessions and per-chat-type threadReplies (#57995)
Merged via squash.

Prepared head SHA: 9ed96dd063
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 22:45:32 -04:00
Gustavo Madeira Santana
d859746862 tests: fix matrix test typing 2026-03-30 22:39:25 -04:00
Gustavo Madeira Santana
47136536c8 tests: use multi-sample CLI startup baselines 2026-03-30 22:35:50 -04:00
Gustavo Madeira Santana
20481d424c cli: clarify cron channel help 2026-03-30 22:33:44 -04:00
Gustavo Madeira Santana
ef6250d9a0 docs: refresh channel delivery examples 2026-03-30 22:33:44 -04:00
Gustavo Madeira Santana
b3f894ea7e fix(matrix): repair fresh invited DMs (#58024)
Merged via squash.

Prepared head SHA: 69b5229632
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 22:30:47 -04:00
Gustavo Madeira Santana
68e49fa791 tests: standardize CLI startup benchmarks 2026-03-30 22:15:56 -04:00
jlxyfll
1c95c41c37 fix(acpx): retain named sessions on queue owner unavailable (#56232) thanks @jlxyfll
Co-authored-by: jl <jlxyfllz@gmail.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-30 22:14:59 -04:00
Peter Steinberger
2cb15255a7 test: fix ci regressions 2026-03-31 02:42:13 +01:00
Shadow
d8d13f2bde Update Discord channel for sharing showcase 2026-03-30 20:39:46 -05:00
Shadow
4bec7622ab Update Discord channel for project submissions 2026-03-30 20:39:02 -05:00
Vincent Koc
67bb3454ee fix(openshell): support remote fs read mutation in tests 2026-03-31 10:30:37 +09:00
Peter Steinberger
3f1d6fe147 test: speed up cli and command suites 2026-03-31 02:25:02 +01:00
Peter Steinberger
6b6ddcd2a6 test: speed up core runtime suites 2026-03-31 02:25:02 +01:00
Peter Steinberger
f7285e0a9e test: speed up extension suites 2026-03-31 02:25:02 +01:00
Vincent Koc
1f6a964e57 fix(ci): handle missing native command capabilities 2026-03-31 10:16:06 +09:00
Vincent Koc
5d8ca42c7d fix(ci): regenerate mac host env policy 2026-03-31 10:12:20 +09:00
Gustavo Madeira Santana
bf6d3176fc scripts: preserve changelog subsection detection 2026-03-30 21:05:31 -04:00
Vincent Koc
2412357bb7 docs: fix QMD install command to use npm package instead of git URL 2026-03-31 10:05:22 +09:00
Vincent Koc
9b6ebc1992 fix(test): trim browser runtime gateway session mocks 2026-03-31 10:02:24 +09:00
Peter Steinberger
4f2df617fe fix: handle Telegram audio auto-transcription 2026-03-31 02:01:01 +01:00
Vincent Koc
121870a085 fix(sandbox): pin remote fs bridge reads (#58016)
* fix(sandbox): pin remote fs bridge reads

* fix(sandbox): reject mount-root reads in remote fs bridge

* fix(sandbox): reject non-regular targets in pinned reads
2026-03-31 09:55:51 +09:00
Vincent Koc
7ae1bb0c77 fix(host-env): block Python package index redirection env vars (#58011)
* fix(host-env): block Python package index redirection vars

* docs(changelog): note Python index override block

* Update src/infra/host-env-security-policy.json

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

* fix(exec): block remaining uv index override env vars

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-31 09:53:32 +09:00
Vincent Koc
873549c8f1 fix(perf): bypass speech facade in core tts runtime 2026-03-31 09:51:47 +09:00
Vincent Koc
fc4ef34478 fix(config): accept block markdown table mode safely 2026-03-31 09:50:41 +09:00
Vincent Koc
8bcaf1a147 fix(test): trim reply command plugin imports 2026-03-31 09:43:54 +09:00
Shakker
81e65e119f test: mock supervisor timeout flows 2026-03-31 01:40:55 +01:00
Shakker
ee38d13f33 test: remove gateway flake from channel mcp notifications 2026-03-31 01:40:55 +01:00
Shakker
a966630a91 test: mock gateway reads in channel mcp tools 2026-03-31 01:40:55 +01:00
Shakker
6d39209430 test: split channel mcp event waiting coverage 2026-03-31 01:40:55 +01:00
Shakker
f8c7512ca5 test: stabilize channel event wait mcp flow 2026-03-31 01:40:55 +01:00
Shakker
afe4a4b260 test: clean test planner typing edges 2026-03-31 01:40:55 +01:00
Shakker
d46f64199a fix: retry bundled runtime dependency staging 2026-03-31 01:40:55 +01:00
Shakker
cefa191417 test: stabilize gateway and session cleanup flows 2026-03-31 01:40:55 +01:00
Shakker
82695bb24d test: remove timeout-prone windows ci waits 2026-03-31 01:40:55 +01:00
Shakker
da03d857f9 test: stabilize recurring windows ci suites 2026-03-31 01:40:55 +01:00
Shakker
6ab0f62b3b test: stabilize remaining windows ci timeouts 2026-03-31 01:40:55 +01:00
Shakker
7d70b1b51e test: stabilize windows registry cleanup flows 2026-03-31 01:40:55 +01:00
Shakker
72cb2a88f1 test: fix planner typing assertions 2026-03-31 01:40:55 +01:00
Shakker
5fb19f296a test: complete exec timeout child lifecycle 2026-03-31 01:40:55 +01:00
Shakker
1dda032531 style: format rebased main files 2026-03-31 01:40:55 +01:00
Shakker
020858647d test: fix qmd and discord ci regressions 2026-03-31 01:40:55 +01:00
Shakker
a8ba6f2c03 test: stabilize channel lifecycle timers in ci 2026-03-31 01:40:55 +01:00
Shakker
82681ba215 test: stabilize exec timeout assertions on windows 2026-03-31 01:40:55 +01:00
Shakker
56c9e2493b test: harden windows timeout-sensitive suites 2026-03-31 01:40:55 +01:00
Shakker
b878a34591 test: stabilize windows flow and session cleanup tests 2026-03-31 01:40:55 +01:00
Shakker
2ff7bb604c test: update planner expectations for current catalog 2026-03-31 01:40:55 +01:00
Shakker
9590e2ccae test: stabilize windows task registry and exec timeouts 2026-03-31 01:40:55 +01:00
Shakker
7ec3674b46 test: stabilize discord and channel mcp ci coverage 2026-03-31 01:40:55 +01:00
Shakker
ab0af5997d test: isolate browser snapshot navigation from proxy env 2026-03-31 01:40:55 +01:00
Shakker
4892c60ee5 test: avoid suite gateway hooks in channel mcp 2026-03-31 01:40:55 +01:00
Gustavo Madeira Santana
8d4040af58 fix(tests): align matrix verification DM fixtures 2026-03-30 20:32:49 -04:00
Gustavo Madeira Santana
31a4b45db0 Maintainer: split PR workflow script modules 2026-03-30 20:28:32 -04:00
scoootscooob
eba41dae4f fix(exec): dedupe Discord approval delivery (#58002)
* fix(exec): dedupe Discord approval delivery

* Update extensions/discord/src/approval-native.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 17:27:34 -07:00
Gustavo Madeira Santana
7b7d7cc743 Matrix: short-circuit aborted monitor startup 2026-03-30 20:13:33 -04:00
Gustavo Madeira Santana
61ae6d7201 Matrix: trim shared test import churn 2026-03-30 20:13:32 -04:00
Gustavo Madeira Santana
f96e5bec39 Diffs: normalize viewer payload languages 2026-03-30 20:12:19 -04:00
Vincent Koc
af0c0862f2 fix(gateway): preserve shared-auth rate limits during mixed handshakes (#57647)
* fix(gateway): preserve shared-auth handshake rate limits

* fix(gateway): scope shared-auth lockouts to shared-auth handshakes
2026-03-31 09:08:57 +09:00
Vincent Koc
a30214a624 fix(heartbeat): block owner-only auth inheritance for exec events (#57652) 2026-03-31 09:06:51 +09:00
Vincent Koc
91f7a6b0fd fix(gateway): revoke active sessions on token rotation (#57646) 2026-03-31 09:05:34 +09:00
Gustavo Madeira Santana
bd957a3a8b Matrix: trim test import breadth 2026-03-30 19:54:40 -04:00
Gustavo Madeira Santana
fa2e051bb6 Maintainer: tighten PR workflow script
Reduce prep and merge friction in the PR wrapper by keeping rebases explicit, reusing doc-only gate results, and making review output terminal-first.

Also add clearer baseline-noise guidance for unrelated local gate failures plus worktree listing and cleanup helpers.
2026-03-30 19:48:46 -04:00
Gustavo Madeira Santana
e11b5d584c Tests: isolate Matrix extension hotspots 2026-03-30 19:36:29 -04:00
Gustavo Madeira Santana
ca6432b0d9 Skills: harden heap snapshot diffing 2026-03-30 19:36:12 -04:00
Gustavo Madeira Santana
bbd495ed63 plugins: quiet scoped manifest id warnings 2026-03-30 19:35:09 -04:00
end
2b2edaa01d fix(matrix): correct DM classification with three-tier is_direct logic and 2-member guard (#57124)
Merged via squash.

Prepared head SHA: e2ff0d5e96
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 18:56:00 -04:00
scoootscooob
dd9d0bdd8e fix(exec): harden shell-side approval guardrails (#57839)
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
2026-03-30 15:49:24 -07:00
scoootscooob
9ff57ac479 refactor(exec): unify channel approvals and restore routing/auth (#57838)
* fix(exec): add shared approval runtime

* fix(exec): harden shared approval runtime

* fix(exec): guard approval expiration callbacks

* fix(exec): handle approval runtime races

* fix(exec): clean up failed approval deliveries

* fix(exec): restore channel approval routing

* fix(exec): scope telegram legacy approval fallback

* refactor(exec): centralize native approval delivery

* fix(exec): harden approval auth and account routing

* test(exec): align telegram approval auth assertions

* fix(exec): align approval rebase followups

* fix(exec): clarify plugin approval not-found errors

* fix(exec): fall back to session-bound telegram accounts

* fix(exec): detect structured telegram approval misses

* test(exec): align discord approval auth coverage

* fix(exec): ignore discord dm origin channel routes

* fix(telegram): skip self-authored message echoes

* fix(exec): keep implicit approval auth non-explicit
2026-03-30 15:49:02 -07:00
Gustavo Madeira Santana
e7e15b92bd Chore: remove orphaned agent workflow 2026-03-30 18:43:14 -04:00
Gustavo Madeira Santana
b9f5d02f04 fix(matrix): restore E2EE for one-off CLI sends (#57936)
Merged via squash.

Prepared head SHA: 4b79fbea22
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 17:28:33 -04:00
mappel-nv
5cc0bc936c Gateway: open config files without shell interpolation (#57921)
* Gateway: open config files without shell interpolation

Co-authored-by: peteryuqin <peter.yuqin@gmail.com>

* Gateway: align config opener review fixes

* Gateway: tidy config opener logging

* Gateway: simplify config opener error path

* Gateway: cover Windows config opener test path

* Gateway: use literal Windows config open path

---------

Co-authored-by: peteryuqin <peter.yuqin@gmail.com>
2026-03-30 15:21:25 -06:00
Dinakar Sarbada
62d6cfedee fix(doctor/plugins): skip unused Matrix inspector loads and honor enabledByDefault startup plugins (#57931)
Merged via squash.

Prepared head SHA: 634794b954
Co-authored-by: dinakars777 <250428393+dinakars777@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 17:06:04 -04:00
Gustavo Madeira Santana
9a94578d47 Diffs: fall back on invalid language hints (#57902)
Merged via squash.

Prepared head SHA: 567ca3a56f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:30:05 -04:00
Gustavo Madeira Santana
66777e140e Diffs: return schema-shaped plugin config (#57904)
Merged via squash.

Prepared head SHA: df95f53aaa
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:27:11 -04:00
Gustavo Madeira Santana
07900facf6 Diffs: skip unused render targets (#57909)
Merged via squash.

Prepared head SHA: 9972f3029f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:21:08 -04:00
Agustin Rivera
30a1690323 fix(diffs): harden viewer proxy access (#57912)
* fix(diffs): harden viewer proxy access

* fix(diffs): restore mapped loopback access
2026-03-30 14:17:27 -06:00
Altay
910134b702 fix(memory): stabilize qmd collection scoping 2026-03-30 22:41:21 +03:00
Altay
9c25544e6c test(ci): fix stale regression expectations (#57899) 2026-03-30 22:31:13 +03:00
Gustavo Madeira Santana
4a6267bfe1 Diffs: preserve base paths for viewer assets 2026-03-30 15:28:16 -04:00
Gustavo Madeira Santana
b96b1efc69 Changelog: restore Matrix history entry 2026-03-30 15:14:53 -04:00
chain710
943163a419 feat(matrix): add group chat history context for agent triggers (#57022)
Merged via squash.

Prepared head SHA: b6f88b72e8
Co-authored-by: chain710 <486539+chain710@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 15:10:43 -04:00
Jacob Tomlinson
8deb9522f3 Guard marketplace and Ollama network requests (#57850)
* Plugins: guard marketplace and Ollama fetches

* Ollama: pin guarded host allowlist
2026-03-30 20:08:38 +01:00
Jacob Tomlinson
e277a37f89 Infra: block compiler env overrides (#57832) 2026-03-30 20:06:32 +01:00
Jacob Tomlinson
cfe1445953 Sandbox: sanitize SSH subprocess env (#57848)
* Sandbox: sanitize SSH subprocess env

* Sandbox: add sanitize env undefined test
2026-03-30 20:05:57 +01:00
Jacob Tomlinson
f0af186726 gateway: ignore bearer-declared HTTP operator scopes (#57783)
* gateway: ignore bearer-declared HTTP operator scopes

* gateway: key HTTP bearer guards to auth mode

* gateway: refresh rebased HTTP regression expectations

* gateway: honor resolved HTTP auth method

* gateway: remove duplicate openresponses owner flags
2026-03-30 20:04:33 +01:00
Jacob Tomlinson
2a75416634 CLI: reset remote URL after trust decline (#57828)
Co-authored-by: zsxsoft <git@zsxsoft.com>
2026-03-30 20:03:06 +01:00
Jacob Tomlinson
ad77666054 fix(voice-call): canonicalize Telnyx replay request keys (#57829) 2026-03-30 20:01:43 +01:00
Agustin Rivera
e65c265e89 Security: block exec approval shell carrier targets (#57871)
* Security: block exec approval shell carrier targets

* Tests: tighten exec approval carrier regression assertions
2026-03-30 12:35:04 -06:00
Mariano
9d9cf0d8ff Tasks: route one-task emergence through parent flows (#57874) 2026-03-30 20:25:01 +02:00
Mariano
7590c22db7 Tasks: add minimal flow registry scaffold (#57865) 2026-03-30 19:57:26 +02:00
Devin Robison
8c83128fc3 Discord: fix Group DM component interaction routing and auth (#57763)
* Discord: fix Group DM component interaction routing and auth

* Update tests
2026-03-30 11:17:53 -06:00
Devin Robison
8fdb19676a Fix Discord native commands bypassing group DM channel allowlist (#57735)
* Fix Discord native commands bypassing group DM channel allowlist

* Fix linting

* Update tests
2026-03-30 11:17:36 -06:00
Gustavo Madeira Santana
dd17dae3e5 Matrix: drop unused MatrixClient constructor params 2026-03-30 13:17:02 -04:00
Gustavo Madeira Santana
1ea85a5d0b Matrix: remove stale monitor mention regex param 2026-03-30 13:17:02 -04:00
Shakker
e8b0d57eb6 test: isolate browser navigation tests from host proxy env 2026-03-30 18:10:08 +01:00
Shakker
8746e2e216 fix: restore cli registry side-effect option 2026-03-30 18:10:08 +01:00
Shakker
ba7c98ab51 fix: align outbound media root tests with config-derived tmp paths 2026-03-30 18:10:08 +01:00
Ayaan Zaidi
1b557ffe65 fix(plugins): keep snapshot hook loads isolated 2026-03-30 22:00:54 +05:30
joelnishanth
f849b8de97 hooks: default hooks.internal.enabled to true so bundled hooks load on fresh installs
Made-with: Cursor
2026-03-30 22:00:54 +05:30
Jacob Tomlinson
3886b65ef2 fix(gateway): require node pairing before enabling node commands (#57777)
* Gateway: require node pairing for node commands

* Gateway: request node pairing on initial connect

* Gateway: filter pending node pairing commands
2026-03-30 17:29:28 +01:00
Jacob Tomlinson
6b38815f86 fix(gateway): tighten tools invoke HTTP guardrails (#57771)
* fix(gateway): tighten tools invoke HTTP guardrails

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>

* fix(security): centralize gateway HTTP deny defaults

* fix(gateway): drop duplicate scope guard after rebase

---------

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 17:16:33 +01:00
Jacob Tomlinson
1ca4261d7e fix(media): keep local roots configuration-derived (#57770)
* fix(media): keep local roots configuration-derived

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>

* fix(media): simplify local root lookup

* fix(media): keep legacy local roots export
2026-03-30 17:15:03 +01:00
Shakker
aff6883f93 fix: avoid over-sharding single include-file test batches 2026-03-30 17:14:02 +01:00
Shakker
c22edbb8ee test: align ci regression stubs with production behavior 2026-03-30 17:11:06 +01:00
Shakker
555a4d896c test: stabilize media attachment cache path assertions 2026-03-30 17:11:06 +01:00
Shakker
4c45fc3575 test: remove telegram extension dependency from reply command tests 2026-03-30 17:11:06 +01:00
Jacob Tomlinson
17d0be02f2 fix(gateway): bind OpenResponses HTTP ingress as non-owner (#57778)
* fix(gateway): bind OpenResponses HTTP ingress as non-owner

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>

* test(gateway): cover streaming OpenResponses non-owner ingress

---------

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 17:05:29 +01:00
Jacob Tomlinson
1a75906a6f Exec approvals: prevent interpreter allow-always persistence (#57772)
* Exec approvals: block interpreter allow-always persistence

* Exec approvals: normalize interpreter allowlist formatting

* Exec approvals: normalize interpreter allowlist wrapping

* Exec approvals: tighten awk regression coverage

* Exec approvals: harden awk interpreter coverage
2026-03-30 17:03:54 +01:00
pgondhi987
b7b46ad185 fix(skills): replace readFileSync with symlink-safe, root-confined skill file loader (#57519)
* fix: replace readFileSync with symlink-safe, root-confined skill file loader

* fix(skills): preserve directory-name fallback when frontmatter omits name

* fix: harden skill loader path containment

---------

Co-authored-by: Jacob Tomlinson <jacobtomlinson@users.noreply.github.com>
2026-03-30 17:03:05 +01:00
Jacob Tomlinson
7a5c5f33d0 Infra: block auth env vars from workspace dotenv (#57767)
* Infra: block auth env vars from workspace dotenv

* Infra: block workspace dotenv auth key variants

* Infra: block workspace dotenv live auth keys
2026-03-30 17:01:22 +01:00
Jacob Tomlinson
29cb1e3c7e Gateway: tighten HTTP tool invoke authorization (#57773)
* Gateway: harden HTTP tool invoke access

* Gateway: strengthen HTTP tools invoke regression coverage

* Gateway: keep owner-only tools off HTTP
2026-03-30 16:59:40 +01:00
Jacob Tomlinson
ae703ab0e7 infra: harden identifier entropy and delay jitter (#57744)
* infra: harden identifier entropy and delay jitter

* test: make randomness hardening deterministic in CI
2026-03-30 16:57:30 +01:00
Jacob Tomlinson
32a4a47d60 Agents: pin apply-patch workspace mutations (#56016)
* Agents: pin apply-patch file ops to workspace

* Agents: resolve apply-patch review feedback

* Infra: fallback pinned path helper spawn failures
2026-03-30 16:49:49 +01:00
pgondhi987
6d341cf366 fix(auto-reply): thread per-agent tools.exec defaults into reply directives (#57689)
* fix(auto-reply): thread per-agent tools.exec defaults into exec overrides

* test(auto-reply): add session-override and inline-directive priority tests for exec agent defaults
2026-03-30 16:46:54 +01:00
samzong
09bb93c6e0 fix(subagents): correct duration display showing 5-6x inflated runtime (#57739)
Merged via squash.

Prepared head SHA: 018bbbca4d
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-30 23:44:36 +08:00
Jacob Tomlinson
f011d0be28 fix(gateway): treat OpenAI HTTP ingress as non-owner (#57769)
Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 16:26:53 +01:00
Sean
c6f2db1506 fix: prevent gateway attachment offload regressions (#55513) (thanks @Syysean)
* feat(gateway): implement claim check pattern to prevent OOM on large attachments

* fix: sanitize mediaId, refine trimEnd, remove warn log, add threshold and absolute path

* fix: enforce maxBytes before decoding and use dynamic path from saveMediaBuffer

* fix: enforce absolute maxBytes limit before Buffer allocation and preserve file extensions

* fix: align saveMediaBuffer arguments and satisfy oxfmt linter

* chore: strictly enforce linting rules (curly braces, unused vars, and error typing)

* fix: restrict offload to mainstream mimes to avoid extension-loss bug in store.ts for BMP/TIFF

* fix: restrict offload to mainstream mimes to bypass store.ts extension-loss bug

* chore: document bmp/tiff exclusion from offload whitelist in MIME_TO_EXT

* feat: implement agent-side resolver for opaque media URIs and finalize contract

* fix: support unicode media URIs and allow consecutive dots in safe IDs based on Codex review

* fix(gateway): enforce strict fail-fast for oversized media to prevent OOM bypass

* refactor(gateway): harden media offload with performance and security optimizations

This update refines the Claim Check pattern with industrial-grade guards:

- Performance: Implemented sampled Base64 validation for large payloads (>4KB) to prevent event loop blocking.
- Security: Added null-byte (\u0000) detection and reinforced path traversal guards.
- I18n: Updated media-uri regex to a blacklist-based character class for Unicode/Chinese filename support, with oxlint bypass for intentional control regex.
- Robustness: Enhanced error diagnostics with JSON-serialized IDs.

* fix: add HEIC/HEIF to offload allowlist and pass maxBytes to saveMediaBuffer

* fix(gateway): clean up offloaded media files on attachment parse failure

Address Codex review feedback: track saved media IDs and implement best-effort cleanup via deleteMediaBuffer if subsequent attachments fail validation, preventing orphaned files on disk.

* fix(gateway): enforce full base64 validation to prevent whitespace padding bypass

Address Codex review feedback: remove early return in isValidBase64 so padded payloads cannot bypass offload thresholds and reintroduce memory pressure. Updated related comments.

* fix(gateway): preserve offloaded media metadata and fix validation error mapping

Address Codex review feedback:
- Add \offloadedRefs\ to \ParsedMessageWithImages\ to expose structured metadata for offloaded attachments, preventing transcript media loss.
- Move \erifyDecodedSize\ outside the storage try-catch block to correctly surface client base64 validation failures as 4xx errors instead of 5xx \MediaOffloadError\.
- Add JSDoc TODOs indicating that upstream callers (chat.ts, agent.ts, server-node-events.ts) must explicitly pass the \supportsImages\ flag.

* fix(agents): explicitly allow media store dir when loading offloaded images

Address Codex review feedback: Pass getMediaDir() to loadWebMedia's localRoots for media-uri refs to prevent legacy path resolution mismatches from silently dropping large attachments.

* fix(gateway): resolve attachment offload regressions and error mapping

Address Codex review feedback:
- Pass \supportsImages\ dynamically in \chat.ts\ and \gent.ts\ based on model catalog, and explicitly in \server-node-events.ts\.
- Persist \offloadedRefs\ into the transcript pipeline in \chat.ts\ to preserve media metadata for >2MB attachments.
- Correctly map \MediaOffloadError\ to 5xx (UNAVAILABLE) to differentiate server storage faults from 4xx client validation errors.

* fix(gateway): dynamically compute supportsImages for overrides and node events

Address follow-up Codex review feedback:

- Use effective model (including overrides) to compute \supportsImages\ in \gent.ts\.

- Move session load earlier in \server-node-events.ts\ to dynamically compute \supportsImages\ rather than hardcoding true.

* fix(gateway): resolve capability edge cases reported by codex

Address final Codex edge cases:
- Refactor \gent.ts\ to compute \supportsImages\ even when no session key is present, ensuring text-only override requests without sessions safely drop attachments.
- Update catalog lookups in \chat.ts\, \gent.ts\, and \server-node-events.ts\ to strictly match both \id\ and \provider\ to prevent cross-provider model collisions.

* fix(agents): restore before_install hook for skill installs

Restore the plugin scanner security hook that was accidentally dropped during merge conflict resolution.

* fix: resolve attachment pathing, defer parsing after auth gates, and clean up node-event mocks

* fix: resolve syntax errors in test-env, fix missing helper imports, and optimize parsing sequence in node events

* fix(gateway): re-enforce message length limit after attachment parsing

Adds a secondary check to ensure the 20,000-char cap remains effective even after media markers are appended during the offload flow.

* fix(gateway): prevent dropping valid small images and clean up orphaned media on size rejection

* fix(gateway): share attachment image capability checks

* fix(gateway): preserve mixed attachment order

* fix: fail closed on unknown image capability (#55513) (thanks @Syysean)

* fix: classify offloaded attachment refs explicitly (#55513) (thanks @Syysean)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 20:54:40 +05:30
Shakker
3ad747e25f style: apply formatter cleanups 2026-03-30 16:20:27 +01:00
Shakker
ab141df4b5 Skills: tighten env path guidance 2026-03-30 16:10:13 +01:00
Shakker
a3de1f5f55 Skills: prefer active OpenClaw paths 2026-03-30 16:10:13 +01:00
Ayaan Zaidi
08d365f481 test: pin android explicit setup auth selection 2026-03-30 20:39:20 +05:30
Ayaan Zaidi
fa150f8828 fix: use explicit setup auth for android gateway connect 2026-03-30 20:39:20 +05:30
Ayaan Zaidi
deead11dcd fix(android): restore setup-code operator bootstrap connect 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
2dced6b4a0 fix: allow setup-code bootstrap auth for operator pairing 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
e0281849c0 fix: unblock android onboarding after bootstrap pairing 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
fec329ce8d fix: handle android bootstrap-only setup codes 2026-03-30 20:39:19 +05:30
Robin Waslander
4d369a3400 harden session-status tool visibility guard for all callers 2026-03-30 16:48:12 +02:00
Jacob Tomlinson
5cca380840 msteams: filter thread history by sender allowlist (#57723)
* msteams: filter thread history by sender allowlist

* tests: merge msteams thread authz coverage

* msteams: preserve thread allowlist fallback matching
2026-03-30 15:38:26 +01:00
Jacob Tomlinson
7e08669715 synology-chat: add webhook in-flight guard (#57722)
* synology-chat: add webhook in-flight guard

* tests: clarify synology in-flight limit assertion

* synology-chat: scope webhook in-flight budget per account
2026-03-30 15:37:02 +01:00
Jacob Tomlinson
7a953a5227 Plugins: block install when source scan fails (#57729)
* Plugins: block unsafe install scan fallthrough

* Tests: normalize install scanner formatting

* Plugins: avoid duplicate scan failure messaging

* Plugins: preserve hook install block codes
2026-03-30 15:36:08 +01:00
Jacob Tomlinson
8db20c1965 sandbox: block sensitive external bind sources (#56024)
* sandbox: block sensitive external bind sources

* sandbox: cache blocked bind paths

* sandbox: harden blocked bind path aliases

* sandbox: block os-home bind secrets

* sandbox: refresh blocked bind path aliases
2026-03-30 15:34:53 +01:00
Jacob Tomlinson
3216df7923 gateway: enforce embeddings HTTP write scope (#57721) 2026-03-30 15:32:03 +01:00
Robin Waslander
85647949a4 tighten phone-control scope helper extraction 2026-03-30 16:17:17 +02:00
Jacob Tomlinson
c5c10adc02 gateway: trim control UI bootstrap payload (#57727) 2026-03-30 15:08:19 +01:00
Robin Waslander
847912f3e2 harden phone-control command scope checks 2026-03-30 15:52:55 +02:00
Jacob Tomlinson
3b9dab0ece OpenShell: harden mirror sync boundaries (#57693)
* OpenShell: harden mirror sync boundaries

* OpenShell: polish mirror hardening tests

* OpenShell: preserve trusted mirror symlinks

* OpenShell: bound mirror fs work globally
2026-03-30 14:51:44 +01:00
Robin Waslander
a4e447a16e harden talk-voice config persistence scope checks 2026-03-30 15:38:37 +02:00
Jacob Tomlinson
ee52f64226 Discord: gate audio preflight on member access (#57695)
* Discord: gate audio preflight on member access

* Discord: trim unauthorized sender logging

* CI: retrigger after review follow-up

* Discord: document blocked-sender log privacy
2026-03-30 14:38:22 +01:00
Jacob Tomlinson
a77928b108 Gateway: harden node event trust boundaries (#57691)
* Gateway: harden node event trust boundaries

* Gateway: preserve trusted summary prefixes

* Gateway: prefix multiline channel summaries
2026-03-30 14:22:15 +01:00
Ayaan Zaidi
9d5c5230c5 fix: restore default HTTP operator scopes (#57596) (thanks @openperf) 2026-03-30 18:51:13 +05:30
openperf
3d659fd356 refactor(gateway ): remove unreachable null check in resolveGatewayRequestedOperatorScopes 2026-03-30 18:51:13 +05:30
openperf
fe2eb185ff fix(gateway ): restore default operator scopes for pure HTTP token auth 2026-03-30 18:51:13 +05:30
Jacob Tomlinson
8b88b927cb gateway: clear unbound scopes for trusted-proxy auth (#57692)
* gateway: clear unbound scopes for trusted-proxy auth

* gateway: isolate trusted-proxy scope test branch
2026-03-30 14:19:00 +01:00
Jacob Tomlinson
566fb73d9d reply: enforce ACP attachment roots (#57690)
* reply: enforce ACP attachment roots

* media: harden local attachment cache reads

* reply: clarify ACP attachment skip logs

* reply: keep ACP attachments path-only
2026-03-30 14:04:02 +01:00
Jacob Tomlinson
3834d47099 MS Teams: validate webhook auth before JSON parsing (#57686) 2026-03-30 13:46:40 +01:00
pgondhi987
bc3b05dce4 fix(infra): block BROWSER, GIT_EDITOR, GIT_SEQUENCE_EDITOR from inherited host env (#57559) 2026-03-30 12:31:04 +01:00
pgondhi987
c4fa8635d0 fix(telegram): gate audio preflight transcription on sender authorization (#57566)
* Telegram: gate audio preflight transcription on sender authorization

* fix: honor telegram audio preflight command auth
2026-03-30 12:19:31 +01:00
Vincent Koc
348b094fe8 fix(test): satisfy telegram pairing seam 2026-03-30 20:05:29 +09:00
Kunal Karmakar
34b0a19a16 fix: use azure-openai-responses for Azure custom providers (#50851) (thanks @kunalk16)
* Add azure-openai-responses

* Unit tests update for updated API

* Add entry for PR #50851

* Add comma to address PR comment

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

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Update changelog stating Azure OpenAI only

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

* Add references

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Fix generated file

* Add azure openai responses to OPENAI_RESPONSES_APIS

* Add azure openai responses to createParallelToolCallsWrapper

* Adding azure openai responses to attempt.ts

* Add azure openai responses to google.ts

* Address PR comment on sanitization of output

* Revert commit

* Address PR comment on sanitization of output

* Revert commit

* Address PR comment on sanitization of output

* Revert commit

* Fix changelog

* Fix linting

* fix: cover azure responses wrapper path (#50851) (thanks @kunalk16)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 16:17:03 +05:30
Vincent Koc
2fbd5e3f5f fix(test): trim telegram command registry imports 2026-03-30 19:34:47 +09:00
Vincent Koc
69916e8082 fix(twitch): align markdown table mode type 2026-03-30 19:32:14 +09:00
Vincent Koc
b7de04f23f fix(memory): preserve shared qmd collection names (#57628)
* fix(memory): preserve shared qmd collection names

* fix(memory): canonicalize qmd path containment
2026-03-30 19:29:35 +09:00
Vincent Koc
85f3136cfc fix(test): use plugin public surfaces in reply command tests 2026-03-30 19:28:10 +09:00
Vincent Koc
54f7221465 fix(slack): restore table block mode seam (#57591)
* fix(slack): restore table block mode seam

Restore the shared markdown/config seam needed for Slack Block Kit table support, while coercing non-Slack block mode back to code.

* fix(slack): narrow table block seam defaults

Keep Slack table block mode opt-in in this seam-only PR, clamp collected placeholder offsets, and align fallback-table rendering with Slack block limits.

* fix(slack): bound table fallback rendering

Avoid spread-based maxima and bound Slack table fallback rendering by row, column, cell-width, and total-output limits to prevent resource exhaustion.

* fix(slack): keep block mode inactive in seam PR

Keep markdown table block mode schema-valid but runtime-resolved to code until the Slack send path is wired to emit table attachments.

* fix(slack): normalize configured block mode safely

Accept configured markdown table block mode at parse time, then normalize it back to code during runtime resolution so seam-only branches do not drop table content.
2026-03-30 19:25:01 +09:00
Vincent Koc
56be744a7a docs: simplify automation decision flowchart to linear path 2026-03-30 19:22:56 +09:00
Vincent Koc
b0738210ff docs: replace ASCII decision tree with mermaid flowchart on automation hub 2026-03-30 19:20:13 +09:00
Vincent Koc
256e3b9b5f docs: add automation overview links to all automation page Related sections
- cron-jobs.md: add Related section (was missing entirely)
- cron-vs-heartbeat.md: add automation overview link, normalize dash style
- tasks.md: add automation overview link
- standing-orders.md: add automation overview, hooks, webhooks links

All automation pages now link back to /automation for navigation.
2026-03-30 19:18:44 +09:00
Vincent Koc
f80310e617 docs: Batch 5 — restructure Nodes group with subgroups
Rename "Nodes and devices" to "Nodes and media" and split into
two subgroups for better navigation:
- Media capabilities: media-understanding, images, audio, camera, tts
- Node features: talk, voicewake, location-command
2026-03-30 19:13:00 +09:00
Vincent Koc
69b72cd977 docs: Batch 4 — create image generation tool page
New page: docs/tools/image-generation.md covering:
- image_generate tool parameters and usage
- Supported providers (OpenAI, Google, fal, MiniMax) with capability matrix
- Config for imageGenerationModel (string and object forms)
- Provider selection order and fallback behavior
- Image editing with reference images
- Provider-specific capabilities (size, aspect ratio, resolution)

Add to Mintlify nav under Tools > Tools group.
2026-03-30 19:12:35 +09:00
Vincent Koc
fa23b5e5a5 docs: Batch 3 — fix misplaced nav entries
- Move plugins/voice-call from Channels > Messaging platforms to
  Tools & Plugins > Plugins (it's a plugin, not a channel integration)
- Add install/clawdock to Install > Containers nav (was orphaned)
2026-03-30 19:09:40 +09:00
Vincent Koc
2b4f600f9c docs: Batch 2 — add Related sections to all channel pages
Add consistent Related sections to 17 channel pages that had none,
linking to: Channels Overview, Pairing, Groups, Channel Routing, Security.

Add Groups and Security links to 4 channel pages (discord, slack,
telegram, whatsapp) that already had partial Related sections.
2026-03-30 19:08:22 +09:00
Vincent Koc
e682b72154 docs: Batch 1 — create automation hub + add Related sections
New page: docs/automation/index.md — single entry point for all automation
mechanisms (heartbeat, cron, tasks, hooks, standing-orders, webhooks) with
a decision flowchart and comparison table.

Add "Related" sections to 5 high-traffic pages that were dead ends:
- gateway/heartbeat.md → links to tasks, cron-vs-heartbeat, timezone, troubleshooting
- concepts/session.md → links to multi-agent, tasks, channel-routing
- concepts/multi-agent.md → links to channel-routing, subagents, ACP, presence, session
- concepts/agent-loop.md → links to tools, hooks, compaction, exec-approvals, thinking
- concepts/timezone.md → links to heartbeat, cron-jobs, date-time

Add automation/index to Mintlify nav as first item in Automation group.
2026-03-30 19:07:18 +09:00
Vincent Koc
104f006916 fix(test): trim reply command registry imports 2026-03-30 19:02:40 +09:00
Vincent Koc
b4ecf2bc33 fix(test): trim matrix bind registry cleanup 2026-03-30 19:02:31 +09:00
Vincent Koc
118a497496 fix(memory): keep qmd session paths roundtrip-safe (#57560) 2026-03-30 18:57:03 +09:00
Vincent Koc
c7d0beb98d fix(ci): harden Windows test cleanup 2026-03-30 18:56:29 +09:00
Frank Yang
43cd29c4af fix(agents): dispose bundled MCP runtime after local runs (#57520)
* fix(agents): dispose bundled MCP runtime after local runs

* fix(agents): scope bundle MCP cleanup to local one-shots

* fix(agents): dispose bundle MCP after local runs

* docs(changelog): note local bundle MCP cleanup fix
2026-03-30 17:12:59 +08:00
Peter Steinberger
926693e993 fix(ci): restore docs formatting and slack test typing 2026-03-30 17:55:02 +09:00
Vincent Koc
9670bd0823 fix(test): trim session binding registry imports 2026-03-30 17:46:44 +09:00
Peter Steinberger
7633b6fe0b docs: add exec approval troubleshooting 2026-03-30 09:45:20 +01:00
Vincent Koc
a804d234cd docs: fix incorrect claim that main-session cron jobs don't create tasks
Source code verified: tryCreateCronTaskRun() in src/cron/service/timer.ts
is called unconditionally for ALL cron job executions (both main-session
and isolated). Main-session cron tasks use silent notify policy by default.

Fixed in:
- automation/tasks.md: update table, TL;DR, Note callout, cron section
- automation/cron-vs-heartbeat.md: fix distinction callout and comparison table
- automation/cron-jobs.md: fix intro and main-session section
2026-03-30 17:32:57 +09:00
Vincent Koc
ad2c3f28bd docs(changelog): summarize background tasks rollout 2026-03-30 17:32:34 +09:00
Peter Steinberger
8a0c377a2f fix: stabilize ci task and docs checks 2026-03-30 09:25:01 +01:00
Ayaan Zaidi
0b632dde8c fix: add facade recursion regression coverage (#57508) (thanks @openperf) 2026-03-30 13:48:21 +05:30
openperf
9a03fe8181 fix(facade-runtime): add recursion guard to facade module loader to prevent infinite stack overflow
Place a sentinel object in the loadedFacadeModules cache before the Jiti
sync load begins.  Re-entrant calls (caused by circular facade references
from constant exports evaluated at module-evaluation time) now receive the
sentinel instead of recursing infinitely.  Once the real module finishes
loading, Object.assign() back-fills the sentinel so any references
captured during the circular load phase see the final exports.

The Jiti load is wrapped in try/catch: on failure the sentinel is removed
from the cache so that subsequent retry attempts re-execute the load
instead of silently returning an empty object.  The function returns the
sentinel (not the raw loaded module) to guarantee a single object identity
for all callers, including those that captured a reference during the
circular load phase.

Also tightens the generic constraint from <T> to <T extends object> so
Object.assign() is type-safe, and propagates the constraint to the
test-utils callers in bundled-plugin-public-surface.ts.

Fixes #57394
2026-03-30 13:48:21 +05:30
Vincent Koc
ae0e1ecf5c docs: add background tasks cross-references across 6 doc pages
Link to /automation/tasks from all pages that mention subagent runs,
ACP runs, or detached background work:

- tools/subagents.md: note that each sub-agent run is tracked as a background task
- tools/acp-agents.md: note that ACP session spawns are tracked as background tasks
- cli/index.md: link tasks section to doc page, add tasks audit subcommand
- concepts/queue.md: note that detached lane runs are tracked as background tasks
- gateway/configuration-reference.md: cron section cross-ref to tasks
- help/faq.md: add tasks link to sub-agent offloading FAQ answer
2026-03-30 16:42:47 +09:00
Vincent Koc
aba220a6aa docs: rewrite tasks page for readability, add mermaid lifecycle diagram
- Rewrite docs/automation/tasks.md to match repo doc style:
  - Add cross-ref blockquote, TL;DR bullets, proper section pacing
  - Replace ASCII lifecycle with a mermaid stateDiagram-v2
  - Add <Tip> for heartbeat wake behavior
  - Split CLI into individual subcommand sections (list/show/cancel/notify/audit)
  - Tighten prose to 3-6 lines per section before a break
  - Add "Status integration" section for task pressure
  - Restructure "How tasks relate" with shorter, punchier subsections
- Fix comparison table link in cron-vs-heartbeat.md
2026-03-30 16:38:59 +09:00
Vincent Koc
77c7eb346b fix(ci): repair docs and task-registry guard 2026-03-30 16:35:18 +09:00
Vincent Koc
8a916652e8 docs: add Background Tasks page and clean up cross-references
New page: docs/automation/tasks.md — comprehensive reference for the task
system covering lifecycle, delivery, notifications, audit, CLI commands,
storage, maintenance, and how tasks relate to cron/heartbeat/sessions.

- Add to Mintlify navigation (docs.json) under Automation group
- Clean up engineer's earlier scattered additions in cron-jobs.md,
  cron-vs-heartbeat.md, and heartbeat.md to be concise and link to the
  new canonical tasks page
- Replace verbose inline explanations with cross-reference links
2026-03-30 16:26:13 +09:00
Vincent Koc
12ae4eee7e fix(slack): complete interactive block delivery (#57473)
* fix(slack): complete interactive block delivery

Related #12602
Related #49528

* docs(changelog): add Slack interactive delivery note

Related #12602

* fix(slack): add reply-blocks helper and tighten directives

Related #12602
Related #49528

* fix(slack): scope style parsing and recheck merged blocks

Related #12602
Related #49528
2026-03-30 16:25:51 +09:00
Vincent Koc
e4e732a77b fix(tasks): remove sqlite merge marker 2026-03-30 16:19:28 +09:00
Vincent Koc
e624fdcf0a docs(tasks): clarify heartbeat, cron, and background runs 2026-03-30 16:19:28 +09:00
Vincent Koc
0a014ca63a perf(tasks): optimize session lookups and sqlite upserts 2026-03-30 16:19:28 +09:00
Vincent Koc
89dbaa87aa fix(memory): add cli qmd session context (#57493) 2026-03-30 16:18:56 +09:00
Patrick Yingxi Pan
1ad88b58d1 feat(matrix): add explicit channels.matrix.proxy config (#56930) (#56931)
Merged via squash.

Prepared head SHA: facdf94b65
Co-authored-by: patrick-yingxi-pan <5210631+patrick-yingxi-pan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 02:51:33 -04:00
Ayaan Zaidi
88716f02de fix: align android sms permission UI state 2026-03-30 11:28:42 +05:30
Ayaan Zaidi
df385a7ed6 test: tighten android node contracts 2026-03-30 11:22:20 +05:30
Ayaan Zaidi
f1e7a5ce5f test: broaden android node advertisement matrix 2026-03-30 11:20:27 +05:30
Ayaan Zaidi
05762ed8d7 test: broaden android nodes tool round trips 2026-03-30 11:18:14 +05:30
Ayaan Zaidi
833d0e3d6f test: broaden android notification handlers 2026-03-30 11:15:44 +05:30
Ayaan Zaidi
94279d09ca test: broaden android location and call log handlers 2026-03-30 11:12:35 +05:30
Ayaan Zaidi
7c93a2bae2 test: broaden android node capability advertisement coverage 2026-03-30 11:10:00 +05:30
Ayaan Zaidi
7304ef6630 test: cover android invoke availability gates 2026-03-30 11:08:05 +05:30
Douglas Lardo
bb2c010e07 fix(delivery): treat Matrix "User not in room" as permanent delivery error (#57426)
Merged via squash.

Prepared head SHA: 6a777197cb
Co-authored-by: dlardo <5000601+dlardo@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 01:35:15 -04:00
Ayaan Zaidi
96ddf30cf1 test: cover android sms permission payloads in nodes tool 2026-03-30 11:04:10 +05:30
Ayaan Zaidi
403cf9070e test: cover android sms send dispatch gating 2026-03-30 11:01:10 +05:30
Ayaan Zaidi
a58ff25769 test: cover android sms search dispatch gating 2026-03-30 11:00:07 +05:30
Ayaan Zaidi
e5fa976f5c test: harden android sms capability coverage 2026-03-30 10:53:55 +05:30
Gustavo Madeira Santana
0b16443fa4 Tests: close ACP manager task registry before temp dir cleanup 2026-03-30 01:17:47 -04:00
Gustavo Madeira Santana
7668793e6c Tests: close task registry before temp dir cleanup 2026-03-30 01:08:54 -04:00
Vincent Koc
1fd8164f01 fix(test): trim acp command registry imports 2026-03-30 14:08:48 +09:00
Vincent Koc
572ed05219 fix(tasks): restore user-facing task wording 2026-03-30 14:08:25 +09:00
Ayaan Zaidi
0462a7fd8c fix: finalize android sms search (#50146) (thanks @scaryshark124) 2026-03-30 10:36:43 +05:30
Gustavo Madeira Santana
74ea42e210 Tests: relax targeted unit planner split assertion 2026-03-30 01:06:32 -04:00
Vincent Koc
4a1f231f1e test(tasks): guard task-registry import boundary (#57487)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes

* refactor(tasks): clarify detached run surfaces

* test(tasks): guard task-registry import boundary
2026-03-30 14:02:48 +09:00
Vincent Koc
3a37421251 refactor(tasks): clarify detached run surfaces (#57485)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes

* refactor(tasks): clarify detached run surfaces
2026-03-30 14:02:13 +09:00
Vincent Koc
8fb247c528 refactor(tasks): guard executor-only producer writes (#57486)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes
2026-03-30 14:00:25 +09:00
Vincent Koc
1c9053802a refactor(cron): split main and detached dispatch (#57482)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch
2026-03-30 13:59:55 +09:00
Vincent Koc
4be290c15f fix(test): trim onboarding registry imports 2026-03-30 13:59:37 +09:00
Gustavo Madeira Santana
10723a0013 Tests: tighten scoped channel account fixtures 2026-03-30 00:59:26 -04:00
Gustavo Madeira Santana
fca8880968 Tests: reuse QMD availability mock type 2026-03-30 00:59:26 -04:00
Vincent Koc
ec13f6d73e refactor(tasks): route subagents through executor (#57481)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor
2026-03-30 13:59:23 +09:00
Vincent Koc
126f77315f refactor(tasks): route acp through executor (#57478)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor
2026-03-30 13:58:51 +09:00
Gustavo Madeira Santana
0e078e8bc0 Runtime: dedupe typing lease logic 2026-03-30 00:58:04 -04:00
Gustavo Madeira Santana
73b128e37d Tests: trim channels add registry imports 2026-03-30 00:54:41 -04:00
Gustavo Madeira Santana
16b452040b Memory: fix QMD doctor contract typing 2026-03-30 00:54:41 -04:00
Gustavo Madeira Santana
b33a18e280 Runtime: remove dead telegram typing lease 2026-03-30 00:52:57 -04:00
Vincent Koc
c842ca0166 fix(test): trim channel account registry imports 2026-03-30 13:50:39 +09:00
Vincent Koc
6a3c68d470 fix(test): trim channels add registry imports 2026-03-30 13:47:25 +09:00
Gustavo Madeira Santana
9d05db7be7 WhatsApp: move heartbeat recipient test into plugin 2026-03-30 00:46:50 -04:00
Gustavo Madeira Santana
6a37ecad82 Supervisor: unblock waits after forced child kill 2026-03-30 00:45:22 -04:00
Gustavo Madeira Santana
6c66d1009b BlueBubbles: move status-issue test into plugin 2026-03-30 00:45:22 -04:00
Vincent Koc
817ac551b6 refactor(tasks): extract delivery policy (#57475)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy
2026-03-30 13:44:59 +09:00
Vincent Koc
8623c28f1d fix(memory): warn when qmd binary is missing (#57467)
* fix(memory): warn when qmd binary is missing

* fix(memory): avoid probing cached qmd managers

* docs(memory): clarify qmd doctor probe behavior

* fix(memory): probe qmd from agent workspace
2026-03-30 13:44:41 +09:00
Vincent Koc
69793db948 fix(test): trim acp context registry imports 2026-03-30 13:39:29 +09:00
Vincent Koc
b26092cf01 fix(test): close task registry sqlite after reset 2026-03-30 13:38:22 +09:00
Gustavo Madeira Santana
c389b05d3c Tests: force-reset session cleanup state between runs 2026-03-30 00:36:43 -04:00
Vincent Koc
20e4d42db3 fix(test): trim onboarding post-write registry imports 2026-03-30 13:36:02 +09:00
Vincent Koc
5b2d9b6505 refactor(tasks): add executor facade (#57474) 2026-03-30 13:35:39 +09:00
Kris Wu
6b255b4dec fix(agents): prevent unhandled rejection when compaction retry times out [AI] (#57451)
* fix(agents): prevent unhandled rejection when compaction retry times out

* fix(agents): preserve compaction retry wait errors

* chore(changelog): add compaction retry timeout entry

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 13:30:13 +09:00
nimbleenigma
aee61dcee0 fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)
* android(gateway): always send string payloadJSON for node.event

(cherry picked from commit 5ca5a0663ad8fbd9f9f654c52a72b423e1e19605)
(cherry picked from commit bce87e7493e52b0e5959548b410500db8d545a50)

* android: restore notification forwarding controls

(cherry picked from commit 98c4486f83d165919d7f8f1d713ff79ec8126ce7)

* android: restore notification picker discovery UX

(cherry picked from commit 276fbe3f80e036b666070a636c89ee073cdaa934)

* android: enforce notification forwarding policy in listener

(cherry picked from commit 502fb761e05ff911ebde2771eebb1e175ec4dbeb)

* fix(android): harden notification quiet hours inputs

(cherry picked from commit 717d5016f52e98601e6b6d678c991c78fa5ca429)

* fix(android): polish notification forwarding settings

(cherry picked from commit ad667899dea45af70fabfd43f43fa38024def23f)

* test: normalize talk config fixture API key placeholders

* fix(android): use persisted recent packages and wall-clock policy time

* fix(android): keep notification forwarding settings editable

* fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 10:00:00 +05:30
Vincent Koc
6af52b4ce3 fix(test): trim outbound session registry imports 2026-03-30 13:29:10 +09:00
Vincent Koc
2849e613ee fix(test): trim target parsing registry imports 2026-03-30 13:19:50 +09:00
Gustavo Madeira Santana
d8ad72bf8d Tests: stabilize session-state cleanup mocks 2026-03-30 00:15:59 -04:00
Vincent Koc
c91f6944cb docs(changelog): add memory session indexer credit 2026-03-30 13:14:42 +09:00
Vincent Koc
ab43bbd62b fix(test): use minimal outbound binding registries 2026-03-30 13:08:13 +09:00
Vincent Koc
fa5827079f refactor(tasks): split delivery state from task runs 2026-03-30 13:03:54 +09:00
Gustavo Madeira Santana
ca2a67e07e Slack: keep auto-thread test context local 2026-03-29 23:56:32 -04:00
zijiess
dca7969b2e fix(agents): classify Anthropic "unexpected error" api_error as transient (#57441)
* fix(agents): classify Anthropic "unexpected error" api_error as transient (#57010)

Anthropic sometimes returns api_error payloads with message "An unexpected
error occurred while processing the response" during mid-stream failures.
This was not matched by API_ERROR_TRANSIENT_SIGNALS_RE, causing the error
to surface as terminal instead of triggering retry/fallback.

Add "unexpected error" to the transient signal regex. This follows the
same pattern as prior fixes for "Internal server error" (#23193) and
overloaded_error 529 (#34535).

Closes #57010

* chore(changelog): add anthropic failover entry

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 12:56:07 +09:00
Gustavo Madeira Santana
91ea844cc0 Slack: move auto-thread coverage into plugin tests 2026-03-29 23:53:48 -04:00
Vincent Koc
dd23e744f4 fix(test): bypass slack facade in message action params test 2026-03-30 12:51:02 +09:00
Vincent Koc
b7d59f7831 fix(memory): export archived qmd session transcripts (#57446)
* fix(memory): export archived qmd session transcripts

* test(memory): separate qmd session listing describe
2026-03-30 12:50:21 +09:00
Vincent Koc
c7106c4285 refactor(tasks): replace generic task mutation api 2026-03-30 12:49:36 +09:00
wangchunyue
16df3de098 fix: stabilize config default-leak landing tests (#56834) (thanks @openperf)
* fix(config): prevent AJV schema defaults from leaking into persisted config

Fixes #56772. Ensures that channel and plugin AJV validations respect the applyDefaults option, preventing runtime defaults from being written to openclaw.json during doctor/update flows.

* test: address review feedback on #56772 fix

- Split validation.channel-metadata.test.ts into applyDefaults true/false cases (fixes CI)

- Update io.write-config.test.ts regression test to use a mock plugin registry, ensuring it actually exercises the AJV default injection path

* fix(config): revert applyDefaults passthrough to prevent required-field regression

Codex-connector correctly identified that BlueBubbles channel schema marks

enrichGroupParticipantsFromContacts as both default:true and required.

Passing applyDefaults:false during write validation would cause required

checks to fail, breaking writeConfigFile entirely.

Reverted validation.ts to always use applyDefaults:true for channel/plugin

AJV validation. The protection against default leakage into persisted config

is fully handled by the persistCandidate change in io.ts (cfgToWrite uses

the pre-validation merge-patched value, not validated.config).

Updated validation.channel-metadata.test.ts to reflect this architecture.

* fix(config): apply legacy web-search normalization to persistCandidate

* fix: stabilize config default-leak landing tests (#56834) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 09:19:17 +05:30
Vincent Koc
22f56433e0 fix(perf): bypass matrix facade for core helpers 2026-03-30 12:43:55 +09:00
Vincent Koc
c142a396f4 fix(memory): rebind qmd collections on pattern drift (#57438) 2026-03-30 12:43:44 +09:00
Gustavo Madeira Santana
313fdf5adf Memory Host: make backend-config path tests portable 2026-03-29 23:41:42 -04:00
Vincent Koc
8c163c14bc feat(tasks): harden maintenance repair paths (#57439)
* feat(tasks): harden maintenance repair paths

* fix(tasks): address follow-up maintenance review comments
2026-03-30 12:37:31 +09:00
Gustavo Madeira Santana
aaf47ca54b WhatsApp: align deliver-reply test mocks with imports 2026-03-29 23:36:38 -04:00
Gustavo Madeira Santana
8b0cbebe43 Telegram: align channel test with runtime probe precedence 2026-03-29 23:34:03 -04:00
Gustavo Madeira Santana
0fe193db6a Discord: restore message utils media-runtime mocks 2026-03-29 23:21:34 -04:00
Gustavo Madeira Santana
3efcc90034 Tests: read packed manifest without shell tar 2026-03-29 23:21:24 -04:00
Gustavo Madeira Santana
1482afae57 Status: keep fast JSON off task audit runtime 2026-03-29 23:17:28 -04:00
Gustavo Madeira Santana
e86a2183df Tests: type package contract npm pack helper 2026-03-29 23:10:58 -04:00
Vincent Koc
c52fac836c feat(tasks): add status health and maintenance command (#57423)
* feat(tasks): add status health and maintenance command

* fix(tasks): address status and maintenance review feedback
2026-03-30 12:10:44 +09:00
Vincent Koc
d28349c48e fix(test): align channel mocks with runtime exports 2026-03-30 12:08:27 +09:00
Vincent Koc
da35718cb2 fix(memory): add qmd mcporter search tool override (#57363)
* fix(memory): add qmd mcporter search tool override

* fix(memory): tighten qmd search tool override guards

* chore(config): drop generated docs baselines from qmd pr

* fix(memory): keep explicit qmd query override on v2 args

* docs(changelog): normalize qmd search tool attribution

* fix(memory): reuse v1 qmd tool after query fallback
2026-03-30 12:07:32 +09:00
Gustavo Madeira Santana
e7984272a7 Tests: make package contract guardrails Windows-safe 2026-03-29 23:07:27 -04:00
Vincent Koc
1421fced04 Update CHANGELOG.md 2026-03-30 12:06:19 +09:00
Yauheni Shauchenka
a6bc51f944 feat(openai): forward text verbosity (#47106)
* feat(openai): forward text verbosity across responses transports

* fix(openai): remove stale verbosity rebase artifact

* chore(changelog): add openai text verbosity entry

---------

Co-authored-by: Ubuntu <ubuntu@vps-1c82b947.vps.ovh.net>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 12:04:35 +09:00
Gustavo Madeira Santana
51e053d0e8 OpenAI: preserve oauth exports in index tests 2026-03-29 22:54:48 -04:00
Vincent Koc
03a03c2dc4 fix(ci): restore skill fixtures and security doc anchors 2026-03-30 11:41:08 +09:00
Gustavo Madeira Santana
f380305ee4 Tests: restore extension plugin test seams 2026-03-29 22:38:44 -04:00
Gustavo Madeira Santana
ecb14338d4 Matrix: keep runtime wrapper off monolithic sdk root 2026-03-29 22:38:44 -04:00
Vincent Koc
e57b3618fc feat(tasks): move task ledger to sqlite and add audit CLI (#57361)
* feat(tasks): move task ledger to sqlite

* feat(tasks): add task run audit command

* style(tasks): normalize audit command formatting

* fix(tasks): address audit summary and sqlite perms

* fix(tasks): avoid duplicate lost audit findings
2026-03-30 11:34:51 +09:00
Ayaan Zaidi
6f09a68ae7 fix: finalize LLM idle timeout landing (#55072) (thanks @liuy) 2026-03-30 08:04:42 +05:30
Ayaan Zaidi
179f713c88 fix: unify idle timeout with runner abort path 2026-03-30 08:04:42 +05:30
Liu Yuan
84b72e66b9 feat: add LLM idle timeout for streaming responses
Problem: When LLM stops responding, the agent hangs for ~5 minutes with no feedback.
Users had to use /stop to recover.

Solution: Add idle timeout detection for LLM streaming responses.
2026-03-30 08:04:42 +05:30
qsam
47839d3b9a fix(mattermost): detect stale websocket after bot disable/enable cycle (#53604)
Merged via squash.

Prepared head SHA: 818d437a54
Co-authored-by: Qinsam <19649380+Qinsam@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-30 07:54:59 +05:30
openperf
c6ded0fa54 fix(gateway): coerce streaming tool-call argument deltas to object in client tools 2026-03-30 07:53:51 +05:30
Gustavo Madeira Santana
6fe24a6f2c Matrix: fix source-checkout runtime wrapper 2026-03-29 22:20:41 -04:00
Robin Waslander
acf8470f09 fix(status): cap cache hit rate at 100% in status display (#57400)
formatTokensCompact() computed cache rate using totalTokens as the
denominator. Legacy rows with undersized totalTokens produced
impossible values like 120%. Use inputTokens + cacheRead + cacheWrite
when prompt-side fields are available, falling back to
max(totalTokens, cacheRead + cacheWrite) for legacy data.

Fixes #26643
2026-03-30 04:09:57 +02:00
Peter Steinberger
25074de838 fix: restore lightweight root help startup 2026-03-30 03:08:53 +01:00
Gustavo Madeira Santana
2481c0a9b6 Tests: keep channel helpers on public plugin surfaces 2026-03-29 22:08:19 -04:00
Ayaan Zaidi
b3c69b941e fix: add orphaned session key migration (#57217)
* fix: add orphaned session key migration

* fix: address session migration review comments
2026-03-30 07:36:46 +05:30
Peter Steinberger
9d005e6fbb fix: stabilize telegram contract runtime coverage 2026-03-30 03:02:25 +01:00
MerlinMiao88888888
9c2d22e77f scripts: respect gateway.bind config when OPENCLAW_GATEWAY_BIND not set (#55453)
* scripts: respect gateway.bind config when OPENCLAW_GATEWAY_BIND not set

* scripts: keep Podman bind precedence focused

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-29 22:00:21 -04:00
Gustavo Madeira Santana
acea28a9bb Telegram: fix extension-fast test seams 2026-03-29 21:54:47 -04:00
Josh Avant
5e4a64848f fix(exec): harden async approval followup delivery in webchat-only sessions (#57359)
* fix(exec): harden approval followup delivery fallback

* refactor(delivery): share best-effort followup routing helpers

* test(subagents): cover webchat-only completion announce delivery

* docs(exec): clarify async followup delivery behavior

* fix(exec): harden delivery downgrade logging

* test(gateway): cover multi-channel best-effort fallback

* fix(exec): preserve webchat origin on session-only followups

* fix(subagents): keep internal announces channel-less
2026-03-29 20:54:13 -05:00
Gustavo Madeira Santana
f44174cf61 Extensions: stabilize telegram registry contracts 2026-03-29 21:42:58 -04:00
Peter Steinberger
e92440e9f4 fix: replace weak randomness in feishu test support 2026-03-30 02:41:25 +01:00
Peter Steinberger
fec51572a3 fix: stabilize gate and extension boundary checks 2026-03-30 02:37:36 +01:00
Vincent Koc
66f8fb9e9b docs: fix P2 in security -- normalize Security audit checklist heading to sentence case 2026-03-30 10:19:51 +09:00
Vincent Koc
542f17a674 docs: fix P2s in hooks -- text language tag on log block, claude-sonnet-4-6 model example 2026-03-30 10:19:51 +09:00
Vincent Koc
7ed5a4a33d docs: fix P2s in faq -- remote mode accordion title, gpt-5.4 model refs, stable/beta cross-reference 2026-03-30 10:19:51 +09:00
Vincent Koc
d19ccde297 docs: fix P2s in configuration-reference -- built-in model catalog, PI_CODING_AGENT_DIR legacy note, deduplicate Identity section 2026-03-30 10:19:51 +09:00
Vincent Koc
0c94420164 docs: fix remaining CLI P1s -- global flags, onboard, logs options
- Add --container global flag
- onboard: add --skip-search, --cloudflare-ai-gateway-account-id, --cloudflare-ai-gateway-gateway-id
- logs: add full options list (--limit, --max-bytes, --follow, --interval, --local-time, etc.)
2026-03-30 10:19:51 +09:00
Vincent Koc
50d815579c docs: consolidate security page structure and add navigation
- Merge 3 duplicate trust-model sections into one (Scope first + Deployment/host trust)
- Promote "What the audit checks" from h3 to h2 (standalone topic, not child of Shared inbox)
- Add "On this page" navigation links at the top for the 1200+ line page
2026-03-30 10:19:51 +09:00
Vincent Koc
726ae0b8af docs: fix discord.md P1s -- internal terminology and wrong CLI command
- Replace "Carbon component instances" with public description
- Fix "openclaw gateway restart" (no such subcommand) with correct restart guidance
2026-03-30 10:19:51 +09:00
Vincent Koc
381bfdf031 docs: fix architecture.md P1s and P2s
- Remove stale "Qwen portal" reference (no such bundled plugin)
- Add activate hook note (legacy alias for register)
- Add api.runtime.imageGeneration documentation (generate, listProviders)
- Fix install command: document --omit=dev flag
- Document external catalog aliases (packages, plugins accepted alongside entries)
2026-03-30 10:19:51 +09:00
Vincent Koc
89f9433fbf docs: add complete plugin hook reference and fix context fields in hooks.md
- Add reference table for all 27 plugin hook names with execution model and return types
- Fix agent:bootstrap context: add missing sessionKey, sessionId, agentId fields
- Fix session patch context: add fastMode, spawnedWorkspaceDir, subagentRole, subagentControlScope
- Fix responseUsage: add backwards-compat "on" value
- Add session:compact:before and session:compact:after payload field documentation
- Remove internal PR reference (#20800)
- Fix handler file resolution: document handler.js and index.js fallbacks
2026-03-30 10:19:51 +09:00
Vincent Koc
cb428aca1c docs: add 11 missing config sections to configuration-reference
Add documentation for config schema sections that existed in source but had
zero coverage in the reference doc:

- diagnostics (otel, cacheTrace, flags, stuckSessionWarnMs)
- update (channel, checkOnStart, auto.*)
- acp (enabled, dispatch, backend, stream.*, runtime.*)
- gateway.tls (enabled, autoGenerate, certPath, keyPath, caPath)
- gateway.reload (mode, debounceMs, deferralTimeoutMs)
- cron.retry (maxAttempts, backoffMs, retryOn)
- cron.failureAlert (enabled, after, cooldownMs, mode)
- auth.cooldowns (billingBackoffHours, billingMaxHours, failureWindowHours)
- logging.maxFileBytes
- session.scope (per-sender vs global)
- session.agentToAgent.maxPingPongTurns (range 0-5)
2026-03-30 10:19:51 +09:00
Gustavo Madeira Santana
b952e404fa Control UI: clear queued connect timeout on stop (#57338)
Merged via squash.

Prepared head SHA: a359fe8367
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 20:54:21 -04:00
Vincent Koc
fb81e3fc7f docs: fix remaining CLI P1s -- doctor, sessions, agents, models auth
- doctor: add --repair/--fix, --force, --generate-gateway-token options
- sessions: add --agent, --all-agents options and cleanup subcommand
- agents: add bindings, bind, unbind, set-identity to command tree
- models auth: add login and login-github-copilot subcommands
2026-03-30 09:51:17 +09:00
Vincent Koc
169bbc82f2 docs: fix security page P1s -- dmScope, heading style, roadmap language
- Add missing per-peer dmScope value to isolation options
- Fix heading style: 3./4. -> 3)/4) for consistency with other numbered sections
- Add channel qualifier to 'Separate Numbers' heading (WhatsApp/Signal/Telegram)
- Remove roadmap speculation ('We may add readOnlyMode later')
2026-03-30 09:46:57 +09:00
Vincent Koc
d3429e0c70 docs: fix FAQ P1s -- model aliases and heartbeat OAuth default
- Remove /model haiku (not a built-in alias); add gemini-flash-lite
- Note custom aliases can be added via config
- Heartbeat: note 1h default for OAuth auth
2026-03-30 09:46:15 +09:00
Vincent Koc
445fed9dc5 docs: add missing field docs and fix config-reference P1s
- Document verboseDefault (off|on|full) and elevatedDefault (off|on|ask|full)
- Heartbeat every: note OAuth default (1h) and disable value (0m)
- Replace internal 'Nano Banana' code name with 'native Gemini image generation'
2026-03-30 09:45:48 +09:00
Vincent Koc
dc64280f1d docs: fix stale CLI reference -- auth choices, agent options, tasks command
- Rebuild --auth-choice enum from BuiltInAuthChoice source (remove ollama, minimax-api,
  minimax-api-lightning; add 15+ missing choices including deepseek, copilot, volcengine, etc.)
- Fix agent command: --verbose accepts on|off not on|full|off; add missing --agent, --reply-to,
  --reply-channel, --reply-account, -m/-t short flags; fix --thinking description
- Add tasks command to command tree and body (list/show/notify/cancel)
- Mark anthropic-cli as deprecated legacy alias
- Remove stale ollama references from custom-base-url/custom-model-id
2026-03-30 09:45:06 +09:00
Vincent Koc
9355925690 docs: fix Mintlify callout syntax in security page
Replace GitHub-flavor > [!WARNING] with Mintlify <Warning> component.
The old syntax renders as a plain blockquote in Mintlify, hiding the most
safety-critical content on the page.
2026-03-30 09:43:33 +09:00
Vincent Koc
b7e2e1b399 docs: fix stale Future Events and wrong log example in hooks.md
- Future Events: session:start/session:end are live plugin hooks, clarify they are planned for internal event stream only
- Log example: session-memory listens to command:new AND command:reset, not just command:new
2026-03-30 09:43:01 +09:00
Vincent Koc
82b6bd7457 docs: fix wrong imports and removed API in architecture.md
- Multi-capability example: use correct SDK subpaths (plugin-entry, media-understanding)
- Remove buildOpenAISpeechProvider (internal, not in public SDK)
- registerHttpHandler: 'obsolete' -> 'removed' (causes hard plugin-load error)
2026-03-30 09:42:11 +09:00
Vincent Koc
32ba94b7b3 docs: fix wrong defaults and config path in FAQ
- session.idleMinutes default: 60 -> 0 (disabled by default, per DEFAULT_IDLE_MINUTES)
- crossContext config path: agents.defaults.tools.message.crossContext -> tools.message.crossContext (matches schema)
- Remove incorrect per-agent tools.message override claim
2026-03-30 09:41:30 +09:00
Vincent Koc
12c92b5fb2 docs: fix wrong defaults and heading in configuration-reference
- maxConcurrent default: 1 -> 4 (matches DEFAULT_AGENT_MAX_CONCURRENT)
- subagents.maxConcurrent example: 1 -> 8 (matches DEFAULT_SUBAGENT_MAX_CONCURRENT)
- Fix section heading: tools.subagents -> agents.defaults.subagents (matches actual config path)
2026-03-30 09:41:09 +09:00
Vincent Koc
9b33380fb6 fix(test): harden qmd release callback typing 2026-03-30 09:37:21 +09:00
Michel Belleau
26f34be20c fix(gateway): /v1/responses tool schema should use flat Responses API format (#57166)
* gateway: fix /v1/responses tool schema to use flat Responses API format

* gateway: fix remaining stale wrapped-format tools in parity tests

* gateway: propagate strict flag through extractClientTools normalization

* fix(gateway): cover responses tool boundary

* Delete docs/internal/vincentkoc/2026-03-30-pr-57166-responses-tool-schema-followup.md

---------

Co-authored-by: Michel Belleau <mbelleau@Michels-MacBook-Pro.local>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 09:36:54 +09:00
Vincent Koc
3034adfdb3 fix(commands): harden fast status and Telegram callbacks 2026-03-30 09:32:53 +09:00
Vincent Koc
8657b65b05 fix(ci): rename extension test support for boundary guards 2026-03-30 09:31:33 +09:00
Vincent Koc
dce61dc920 fix(cli): import task summary helper 2026-03-30 09:31:33 +09:00
Vincent Koc
b82fd50472 fix(test): add extension-safe test helper bridges 2026-03-30 09:31:33 +09:00
Vincent Koc
5bac1aad04 fix(memory): support qmd glob collection flags (#57351) 2026-03-30 09:30:49 +09:00
Harold Hunt
8bf86b4cdf agents: remove xAI auth trace logging (#57342) 2026-03-29 20:29:51 -04:00
Peter Steinberger
f3bf7fe53a chore: bump version to 2026.3.30 2026-03-30 09:28:29 +09:00
Vincent Koc
d26d7c797b fix(memory): add QMD sync parity hooks (#57354)
* fix(memory): add qmd sync parity hooks

* fix(memory): avoid blocking qmd session warm searches
2026-03-30 09:25:37 +09:00
Peter Steinberger
9857d40923 fix(runtime): stabilize image generation auth/runtime loading 2026-03-30 01:14:29 +01:00
Gustavo Madeira Santana
bb42027699 Docs: hide ClawDock from install menu 2026-03-29 20:11:02 -04:00
Vincent Koc
e6445c22aa feat(status): surface task run pressure (#57350)
* feat(status): surface task run pressure

* Update src/commands/tasks.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 09:09:10 +09:00
Gustavo Madeira Santana
93dd25e6b2 Tests: drop dead telegram exec approval imports 2026-03-29 20:08:15 -04:00
Gustavo Madeira Santana
cc04153d01 Agents: reuse shared subagent hook runner type 2026-03-29 20:06:05 -04:00
Daniel Olshansky
6e1f00dc86 [ClawDock] Iteration on the first submission; bug fixes, UX improvements, etc (#23912)
Merged via squash.

Prepared head SHA: 30c5ef37a4
Co-authored-by: Olshansk <1892194+Olshansk@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 20:05:41 -04:00
Peter Steinberger
c2cbdea28c refactor: add approval auth capabilities to more channels 2026-03-30 09:04:08 +09:00
Peter Steinberger
63cbc097b5 refactor(channels): route core through registered plugin capabilities 2026-03-30 01:03:42 +01:00
Peter Steinberger
471e059b69 refactor(plugin-sdk): remove channel-specific sdk shims 2026-03-30 01:03:24 +01:00
Peter Steinberger
bff6a6a9c1 test(config): align optimistic write helpers 2026-03-30 01:02:25 +01:00
Peter Steinberger
47216702f4 refactor(config): use source snapshots for config mutations 2026-03-30 01:02:25 +01:00
Gustavo Madeira Santana
f9bf76067f Agents: fix subagent spawn hook typing 2026-03-29 20:00:52 -04:00
hnshah
19113637e8 fix(memory): keep qmd embeddings active in search mode (#54509)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:59:50 +09:00
yuna78
0033f64e19 gateway: narrow already-running exit code (#26718)
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-03-30 10:59:32 +11:00
dadgo
2885c65c74 fix(memory): point qmd config dir at nested path (#39078)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:59:08 +09:00
Amine Harch el korane
219d4f03bd fix: wire memorySearch.extraPaths to QMD indexing (#57315)
* fix: wire memorySearch.extraPaths to QMD indexing

The 'agents.defaults.memorySearch.extraPaths' config field was documented
to add extra directories to the memory index, but the paths were never
actually passed to the QMD backend. Only 'memory.qmd.paths' worked.

This fix reads extraPaths from the memorySearch config and maps them
to QMD custom path collections, so users can simply configure:

  memorySearch:
    extraPaths:
      - odd-vault
      - /Users/odd/workspace
      - /Users/odd/docs

And have those directories indexed alongside the default memory files.

Closes #57302

* fix: handle per-agent memorySearch.extraPaths overrides + add tests

- Read per-agent overrides from agents.list[].memorySearch.extraPaths
- Agent-specific overrides take priority over defaults
- Falls back to defaults when agent has no overrides
- Added 3 test cases for the feature

* fix: merge defaults + agent overrides instead of replacing

* fix: remove any types from tests, fix merge behavior assertion

* fix(memory): merge qmd extra path collections

* fix(memory): normalize qmd extra path resolution

* fix(memory): type qmd extra path merge

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:58:42 +09:00
Peter Steinberger
cbceb1db76 docs(i18n): sync zh-CN node exec wording 2026-03-30 00:57:27 +01:00
Peter Steinberger
6b8a1b77a0 refactor(nodes): split media and invoke handlers 2026-03-30 00:57:27 +01:00
Vincent Koc
475defdf82 Anthropic: wire explicit service tier params (#45453)
* Anthropic: add explicit service tier wrapper

* Runner: wire explicit Anthropic service tiers

* Tests: cover explicit Anthropic service tiers

* Changelog: note Anthropic service tier follow-up

* fix(agents): make Anthropic service tiers override fast mode

* fix(config): drop duplicate healed sourceConfig

* docs(anthropic): update fast mode service tier guidance

* fix(agents): remove dead Anthropic Bedrock exports

* fix(agents): avoid cross-provider Anthropic tier warnings

* fix(agents): avoid cross-provider OpenAI tier warnings
2026-03-30 08:54:56 +09:00
Peter Steinberger
feed2c42dd test: stabilize subagent spawn harnesses 2026-03-30 00:54:09 +01:00
Vincent Koc
170a3a39d4 fix(test): restore subagent announce timeout mocks 2026-03-30 08:52:04 +09:00
Gustavo Madeira Santana
188fcbfa34 Docs: add zh-CN Diffs page 2026-03-29 19:48:08 -04:00
Gustavo Madeira Santana
c191dc9928 Control UI: preserve seq-gap reconnect state 2026-03-29 19:48:08 -04:00
Peter Steinberger
cf84a03ecf docs: clarify generic channel approval capabilities 2026-03-30 08:46:44 +09:00
Peter Steinberger
3b878e6b86 refactor: move approval auth and payload hooks to generic channel capabilities 2026-03-30 08:46:44 +09:00
Vincent Koc
7008379ff0 test(agents): restore cli runner test seams 2026-03-30 08:43:37 +09:00
Vincent Koc
408f6a5b0b test(matrix): stabilize file sync store persistence checks 2026-03-30 08:43:37 +09:00
Vincent Koc
d6a3580347 fix(lint): clear current main lint blockers 2026-03-30 08:43:37 +09:00
Peter Steinberger
193f781fad fix: stabilize ci and serial test gate 2026-03-30 00:43:01 +01:00
Vincent Koc
0da610a8ec fix(tasks): prefer ACP spawn metadata on merge 2026-03-30 08:42:31 +09:00
Peter Steinberger
c1137ef00d docs(nodes): remove nodes.run references 2026-03-30 00:41:06 +01:00
Peter Steinberger
2255e04b07 test(nodes): update coverage after exec consolidation 2026-03-30 00:41:06 +01:00
Peter Steinberger
5dae663ea4 refactor(nodes): remove nodes.run execution path 2026-03-30 00:41:06 +01:00
Peter Steinberger
dd8d66fc44 refactor: inject subagent announce test seams 2026-03-30 00:40:32 +01:00
Peter Steinberger
f914cd598a refactor(gateway): dedupe self-write config reloads 2026-03-30 00:39:39 +01:00
Peter Steinberger
a27ccee5d9 refactor(config): use source snapshots for config writes 2026-03-30 00:39:39 +01:00
Vincent Koc
c5baf63fa5 docs: deep audit of memory section -- fix icons, beef up engine pages, restructure config reference 2026-03-30 08:39:18 +09:00
Gustavo Madeira Santana
1600c1726e Control UI: reconnect on seq gaps 2026-03-29 19:31:01 -04:00
Peter Steinberger
15c3aa82bf refactor: unify approval forwarding and rendering 2026-03-30 08:28:33 +09:00
Peter Steinberger
8720070fe0 refactor: rename channel approval capabilities 2026-03-30 08:28:33 +09:00
Vincent Koc
53bcd5769e refactor(tasks): unify the shared task run registry (#57324)
* refactor(tasks): simplify shared task run registry

* refactor(tasks): remove legacy task registry aliases

* fix(cron): normalize timeout task status and harden ledger writes

* fix(cron): keep manual runs resilient to ledger failures
2026-03-30 08:28:17 +09:00
Peter Steinberger
e4466c72a2 test: stabilize runner and acp mocks
- reuse the shared cli-runner harness in claude runner tests
- make ACP session metadata and startup tests use stable static mocks
2026-03-30 00:27:52 +01:00
Vincent Koc
bf63264c62 docs: fix Honcho tool names and add CLI commands 2026-03-30 08:26:52 +09:00
Robin Waslander
bdd9bc93f1 fix(cron): deliver full announce output instead of last chunk only (#57322)
resolveCronPayloadOutcome() collapsed announce delivery to the last
deliverable payload. Replace with pickDeliverablePayloads() that
preserves all successful text payloads. Error-only runs fall back to
the last error payload only.

Extract shared isDeliverablePayload() helper. Keep
deliveryPayloadHasStructuredContent scoped to the last payload
to preserve downstream finalizeTextDelivery safeguards.

Fixes #13812
2026-03-30 01:24:45 +02:00
Peter Steinberger
0a4c11061d test: stabilize targeted harnesses
- reduce module-reset mock churn in auth/acp tests
- simplify runtime web mock cleanup
- make canvas reload test use in-memory websocket tracking
2026-03-30 00:23:38 +01:00
Radek Sienkiewicz
4680335b2a docs: fix English link audits (#57039)
Merged via squash.

Prepared head SHA: d20a3b620f
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-30 01:21:00 +02:00
Gustavo Madeira Santana
6c91b27756 Docs: remove internal CLI metadata note 2026-03-29 19:20:43 -04:00
Gustavo Madeira Santana
b0077904a7 Plugins: align CLI metadata loader behavior 2026-03-29 19:20:42 -04:00
Peter Steinberger
9a97c30fad style(test): sort mutate helper imports 2026-03-30 00:17:23 +01:00
Peter Steinberger
f928b81279 test(config): align snapshot fixtures and isolation 2026-03-30 00:17:23 +01:00
Peter Steinberger
89a4f2a34e refactor(config): centralize runtime config state 2026-03-30 00:17:23 +01:00
Armand du Plessis
b888741462 fix: QMD 1.1+ mcporter compatibility with legacy fallback [AI-assisted] (#54728)
* fix: QMD 2.0 mcporter compatibility with v1 fallback [AI-assisted]

QMD 2.0 unified all search modes under a single 'query' MCP tool
with typed sub-queries, replacing search/vector_search/deep_search.

- Default to QMD 2.0 'query' tool with {searches: [...]} format
- Auto-detect version on first call and cache for the session
- Fall back to v1 tool names if 'query' is not found
- Backwards compatible: v1 users get one retry then cached

AI-assisted: Built with Claude (Opus 4.6) via OpenClaw. Fully tested
against QMD 2.0 with mcporter 0.7.3 daemon. v1 fallback path not
live-tested (no v1 instance available). Code reviewed and understood.

* test: add QMD v2 tool format and v1 fallback tests

- Verify mcporter bridge uses 'query' tool with {searches: [...]} format (v2)
- Verify fallback to 'deep_search' with {query, limit} format when v2 not found
- Verify v1 fallback logs a warning for visibility

* fix: address review feedback — multi-collection v1 fallback + test cleanup

- Fix multi-collection v1 fallback: resolve effectiveTool at the top
  of runQmdSearchViaMcporter so stale 'query' tool names from the
  loop are corrected once qmdMcpToolVersion is set to 'v1'
- Assert callCount in v1 fallback test (one v2 attempt + one v1 retry)
- Remove spurious global state reset (qmdMcpToolVersion is per-instance)

* docs: correct version references — breaking change was QMD 1.5, not 2.0

The MCP tool removal (search/vector_search/deep_search → query) happened
in QMD 1.5, not 2.0. QMD 2.0 was the SDK/library refactor.
Updated all comments, test names, and documentation to reflect this.

* fix: respect searchMode when building v2 mcporter queries

When searchMode is 'search' (BM25), only send lex sub-query.
When 'vsearch', only send vec. Default 'query' sends all three
(lex + vec + hyde) for full hybrid search with reranking.

Previously all three sub-queries were always sent regardless of
the configured searchMode, which could trigger unnecessary vector
embedding and HyDE LLM work on setups explicitly requesting
lexical-only search.

Addresses Codex P2 review feedback.

* docs: correct to QMD 1.1.0 — that's the actual version that removed the tools

Per CHANGELOG.md, MCP tools search/vector_search/deep_search were removed
in QMD 1.1.0 (2026-02-20), not 1.5 (which doesn't exist). Versions go
1.0.7 → 1.1.0 → 1.1.1 → 1.1.2 → 1.1.5 → 1.1.6 → 2.0.0.

* fix: remove redundant v1 guard (race condition) + tighten error matching

1. Remove qmdMcpToolVersion !== 'v1' guard from catch block. It's
   redundant (effectiveTool === 'query' already prevents infinite retry)
   and introduces a race condition: concurrent searches that both probe
   with 'query' while version is null would fail after the first sets
   version to 'v1'.

2. Tighten isToolNotFoundError regex to require 'Tool' near 'not found'
   (within 40 chars, no sentence boundary). Prevents false-positives
   when user query text in the mcporter args contains both words.

Addresses Greptile P1 (concurrent-search race) and Codex P2
(overly broad error matching).

* fix: address claude code review — type safety, minScore docs, explicit switch

- Restore type union for tool param instead of bare string
- Add comment explaining minScore omission for v2 (QMD 1.1+ uses
  its own reranking pipeline, no minScore parameter)
- Make buildV2Searches switch exhaustive with explicit case 'query'

* fix: resolve CI failures — oxfmt formatting + TypeScript type errors

- Run oxfmt to fix formatting issues
- Add union return type to resolveQmdMcpTool() and
  runMcporterAcrossCollections() tool param to satisfy tsc
  (string was not assignable to the union type)

* fix(memory): align qmd query collection filters

* fix(memory): narrow qmd missing-tool fallback detection

* fix(memory): ignore qmd timeout text for v1 fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:07:14 +09:00
Peter Steinberger
0fdf724125 test: trim stale extension boundary allowlist 2026-03-30 08:05:59 +09:00
Peter Steinberger
809833ef9d fix(config): recover clobbered config and isolate test paths 2026-03-30 00:05:36 +01:00
Peter Steinberger
d45b997ba9 docs: clarify shared approval delivery 2026-03-30 08:03:59 +09:00
Peter Steinberger
52fb4a149a refactor: share approval interactive renderers 2026-03-30 08:03:59 +09:00
Peter Steinberger
cfac0e8698 refactor: move plugin-owned test support into plugins 2026-03-30 08:03:04 +09:00
Vincent Koc
1ace91ee00 fix(bluebubbles): coalesce URL-only inbound shares 2026-03-30 08:00:25 +09:00
Cypher
924c264a74 fix: inject anthropic service_tier for OAuth auth (#55922)
* fix: inject anthropic service_tier for OAuth auth

Remove the OAuth-token exclusion from createAnthropicFastModeWrapper
so that sk-ant-oat-* requests receive service_tier injection, matching
Claude Code CLI behavior and reducing avoidable 529 overload cascades.

isAnthropicOAuthApiKey remains in use in createAnthropicBetaHeadersWrapper
for beta header selection — it is not dead code after this change.

Fixes #55758

* docs(changelog): note anthropic oauth service tier fix

* Update CHANGELOG.md

---------

Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 07:54:24 +09:00
Gustavo Madeira Santana
e5dac0c39e CLI: keep root help plugin descriptors non-activating (#57294)
Merged via squash.

Prepared head SHA: c8da48f689
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 18:49:57 -04:00
Peter Steinberger
1efef8205c fix: stabilize extensions surface test gate 2026-03-30 07:47:58 +09:00
Peter Steinberger
07c6981c70 refactor: localize zalo test support 2026-03-30 07:47:28 +09:00
Vincent Koc
ed2528e6fb docs: nest memory engine pages under Memory sub-group 2026-03-30 07:45:20 +09:00
Vincent Koc
92d4c62d59 style: format Honcho docs tables 2026-03-30 07:44:35 +09:00
Vincent Koc
70b7f32c7e docs: add Honcho memory engine page 2026-03-30 07:44:35 +09:00
Peter Steinberger
8861cdbb6f refactor(plugin-sdk): untangle extension test seams 2026-03-29 23:43:53 +01:00
Peter Steinberger
c942bd798f docs: purge internal notes 2026-03-29 23:38:25 +01:00
Peter Steinberger
a9984e2bf9 fix(config): reuse in-memory gateway write reloads 2026-03-29 23:38:25 +01:00
Vincent Koc
0e47ce58bc fix(approvals): restore queue targeting and plugin id prefixes 2026-03-30 07:37:50 +09:00
Peter Steinberger
7043705ef3 refactor: split MCP runtime and transport seams 2026-03-29 23:36:37 +01:00
Peter Steinberger
69eea2cb80 refactor: split approval auth delivery and rendering 2026-03-30 07:36:18 +09:00
Vincent Koc
c9eb31382e docs: add dedicated memory engine pages, replace ASCII diagram with Mermaid 2026-03-30 07:34:14 +09:00
Vincent Koc
67b381b928 style: format docs tables 2026-03-30 07:32:20 +09:00
Vincent Koc
143b4c54ba docs: simplify sessions/memory concept pages and fix QMD experimental label 2026-03-30 07:32:20 +09:00
Onur Solmaz
57069f2b2f gitignore: ignore docs/internal 2026-03-30 00:26:13 +02:00
Peter Steinberger
147c2c7389 fix: port safer bundle MCP naming onto latest main (#49505) (thanks @ziomancer) 2026-03-30 07:22:36 +09:00
Peter Steinberger
004bffa1c3 fix: finalize safer MCP tool naming (#49505) (thanks @ziomancer) 2026-03-30 07:22:36 +09:00
ziomancer
97bf38099a docs(mcp): document connection timeout and URL scheme validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
a74091eb98 docs(mcp): add CHANGELOG entries and MCP transport/namespacing docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
14f78debd5 fix(mcp): allow colon in tool names and dispose transport on failed connect
The transcript validator regex rejected namespaced MCP tool names
(e.g., vigil-harbor:memory_status) because colon wasn't in the
allowed character set. Tool call blocks were silently dropped during
session repair, breaking tool-call/result pairing.

Also closes a resource leak: if client.connect() throws after the
transport is instantiated, the transport is now explicitly closed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
414d7306d1 feat(mcp): add HTTP transport support and tool namespacing
MCP tools are now prefixed with their server name (e.g., vigil-harbor:memory_status)
to prevent collisions between tools from different MCP servers and built-in tools.

Adds SSE and StreamableHTTP transport support alongside existing stdio, enabling
connection to remote MCP servers via URL-based config with optional custom headers
and env var substitution. Includes config validation, session lifecycle management,
and 5 new tests for HTTP config edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
Vincent Koc
102b7126c1 style: format session docs tables 2026-03-30 07:18:58 +09:00
Vincent Koc
2880b3d3ff docs: rewrite session management, session pruning, and session tools pages 2026-03-30 07:18:58 +09:00
Peter Steinberger
f8dc4305a5 refactor: share native approval delivery helpers 2026-03-30 07:16:33 +09:00
Mariano
5ef42fc856 Gateway: surface blocked ACP task outcomes (#57203) 2026-03-30 00:15:51 +02:00
Onur Solmaz
2da61e6553 [codex] Move internal development notes to maintainers (#57316)
* docs: move internal notes to maintainers

* docs: drop internal notes agent guidance
2026-03-30 00:15:08 +02:00
Peter Steinberger
d82d6ba0c4 fix: align openai fast mode with priority processing 2026-03-29 23:14:52 +01:00
Peter Steinberger
27519cf061 refactor: centralize node identity resolution 2026-03-29 23:14:22 +01:00
Vincent Koc
ace876b087 fix(gateway): keep startup model warmup static 2026-03-30 07:12:52 +09:00
Vincent Koc
bd89e07baa fix(agents): stop transient live-switch mismatches 2026-03-30 07:12:52 +09:00
Peter Steinberger
e01ca8cfc6 refactor(plugin-sdk): remove direct extension source leaks 2026-03-29 23:11:20 +01:00
Vincent Koc
40446ea27c docs: add cross-links to new memory-search page from reference docs 2026-03-30 07:10:33 +09:00
Vincent Koc
3584a893e8 docs: rewrite sessions/memory section -- compaction, memory, and new memory-search page 2026-03-30 07:10:01 +09:00
Peter Steinberger
6d9a7224aa refactor: unify approval command authorization 2026-03-30 07:06:29 +09:00
Peter Steinberger
6ca81f8ec7 test(gateway): decouple send coverage from telegram specifics 2026-03-29 22:59:32 +01:00
Peter Steinberger
168ab94eee refactor(config): pin runtime snapshot and drop ttl cache 2026-03-29 22:57:31 +01:00
Vincent Koc
22ffe7b1de docs: fix before_install finding field name, add MCP SSE transport docs, add acpx auto-install note 2026-03-30 06:53:35 +09:00
Vincent Koc
6d0abfa50c docs(exec): document denied approval output isolation behavior 2026-03-30 06:53:35 +09:00
Peter Steinberger
3ec000b995 refactor: align same-chat approval routing 2026-03-30 06:52:28 +09:00
Peter Steinberger
f16c176a4c fix: disambiguate legacy mac node identities 2026-03-29 22:47:15 +01:00
Peter Steinberger
2e0682d930 refactor: finish decoupling plugin sdk seams 2026-03-29 22:42:06 +01:00
Peter Steinberger
574d3c5213 fix: make same-chat approvals work across channels 2026-03-30 06:35:04 +09:00
Peter Steinberger
1ca01b738b fix: stabilize exec approval approver routing 2026-03-30 06:25:03 +09:00
Peter Steinberger
216afe275e test: harden packed manifest guardrails 2026-03-29 22:21:29 +01:00
Peter Steinberger
2f19b303c6 build: fix packaged plugin dependency ownership 2026-03-29 22:21:29 +01:00
Peter Steinberger
356adc98d5 test: align schema and redaction assertions 2026-03-29 22:21:29 +01:00
Peter Steinberger
b4badd7704 build: resolve plugin package contract path 2026-03-29 22:21:29 +01:00
Peter Steinberger
e45cc3890b refactor: unify sensitive URL config hints 2026-03-29 22:21:29 +01:00
Peter Steinberger
1318479a2c refactor: extract MCP transport helpers 2026-03-29 22:21:29 +01:00
Peter Steinberger
4635810385 fix: stabilize unit test planner scheduling 2026-03-30 06:17:06 +09:00
Peter Steinberger
9baa853797 test: isolate image read helper coverage 2026-03-29 22:10:37 +01:00
Peter Steinberger
421acd27e1 style: finalize exec formatter cleanup 2026-03-30 06:03:08 +09:00
Peter Steinberger
ddd2cbf03a style: resolve exec formatter follow-up 2026-03-30 06:03:08 +09:00
Peter Steinberger
bb10f60993 style: resolve exec formatter follow-up 2026-03-30 06:03:08 +09:00
Peter Steinberger
276ccd2583 fix(exec): default implicit target to auto 2026-03-30 06:03:08 +09:00
Peter Steinberger
d014f173f1 test: trim stale legacy coverage and repair mocks 2026-03-29 22:00:56 +01:00
Peter Steinberger
63e5c3349e refactor(config): drop obsolete legacy config aliases 2026-03-29 22:00:56 +01:00
Peter Steinberger
d9274444b7 fix: keep beta on newer prereleases 2026-03-30 05:37:01 +09:00
Peter Steinberger
aed87a608e fix: stabilize bundled plugin ci lanes 2026-03-30 05:35:53 +09:00
Peter Steinberger
694bc082a8 fix: resolve acpx MCP secret inputs 2026-03-30 05:30:32 +09:00
Peter Steinberger
35233bae96 refactor: decouple bundled plugin sdk surfaces 2026-03-29 21:20:46 +01:00
Peter Steinberger
5d4c4bb850 fix(exec): restore runtime-aware implicit host default 2026-03-29 21:18:41 +01:00
Onur Solmaz
96df794c12 [codex] Move internal experiment plans under docs-internal (#57262)
* docs: restore internal plan docs under docs-internal

* docs: move internal plans under docs/internal

* docs: restore deleted internal experiment notes

* docs: group internal notes by author

* docs: move Bob internal notes under osolmaz

* docs: move remaining internal notes under osolmaz

* Revert "docs: move remaining internal notes under osolmaz"

* docs: restore experiment architecture note

* docs: normalize osolmaz internal doc authorship

* docs: add identity metadata to internal docs

* docs: document internal development notes

* docs: use Peter in internal docs examples

* docs: move Mariano background task note to internal docs

* Revert "docs: use Peter in internal docs examples"

* docs: use real Peter note in internal docs readme

* docs: rename Mariano background task note

* docs: preserve others internal notes by default

* docs: allow BDFL override for internal notes
2026-03-29 22:18:26 +02:00
Peter Steinberger
f5f8ba6d35 fix: harden bundle MCP session runtime cache (#55090) (thanks @allan0509) 2026-03-30 05:10:32 +09:00
无忌
6477d783e8 Agents: cache bundle MCP runtime per session 2026-03-30 05:10:32 +09:00
Peter Steinberger
73477eee4c fix: harden ACP plugin tools bridge (#56867) (thanks @joe2643) 2026-03-30 05:09:59 +09:00
khhjoe
e24091413c fix: add curly braces for oxlint curly rule; copy postinstall script before pnpm install in Dockerfile 2026-03-30 05:09:59 +09:00
khhjoe
a8c189f463 fix(mcp): serialize result.content instead of wrapper object; warn on missing static assets 2026-03-30 05:09:59 +09:00
khhjoe
3151eb5b48 feat: auto-install acpx deps via npm postinstall on global install 2026-03-30 05:09:59 +09:00
khhjoe
5f628c0bf8 build: copy acpx mcp-proxy.mjs to dist in runtime-postbuild 2026-03-30 05:09:59 +09:00
khhjoe
415899984e feat(mcp): add plugin tools MCP server for ACP sessions
Standalone MCP server that exposes OpenClaw plugin-registered tools
(e.g. memory-lancedb's memory_recall, memory_store, memory_forget)
to ACP sessions running Claude Code via acpx's MCP proxy mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 05:09:59 +09:00
Peter Steinberger
3f5ed11266 fix: clear stalled model resolution lanes 2026-03-30 05:09:26 +09:00
Gustavo Madeira Santana
9b4f26e70a Plugins/CLI: add descriptor-backed lazy root command registration (#57165)
Merged via squash.

Prepared head SHA: ad1dee32eb
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 16:02:59 -04:00
Peter Steinberger
d330782ed1 fix(matrix): stop discovering runtime helper as plugin entry 2026-03-29 21:00:57 +01:00
Gustavo Madeira Santana
dc192d7b2f Build: mirror Matrix crypto WASM runtime deps (#57163)
Merged via squash.

Prepared head SHA: b3aeb9d08a
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 15:57:28 -04:00
Peter Steinberger
82f04ced27 refactor(plugin-sdk): drop legacy provider compat subpaths 2026-03-29 20:55:53 +01:00
Peter Steinberger
cce6d3bbb7 fix: resolve CI type and lint regressions 2026-03-30 04:51:33 +09:00
Peter Steinberger
855878b4f0 fix: stabilize serial test suite 2026-03-30 04:46:04 +09:00
George Zhang
b787669340 docs(plugins): add before_install payload example 2026-03-29 12:35:01 -07:00
George Zhang
2607191d04 refactor(plugins): centralize before_install context shaping 2026-03-29 12:35:01 -07:00
George Zhang
9a07fd83fb docs(plugins): describe before_install policy foundation 2026-03-29 12:35:01 -07:00
George Zhang
b5d48d311c test(plugins): cover before_install policy metadata 2026-03-29 12:35:01 -07:00
George Zhang
150faba8d1 plugins: enrich before_install policy context 2026-03-29 12:35:01 -07:00
George Zhang
9ea0b76f06 docs(plugins): document before_install hook 2026-03-29 12:35:01 -07:00
George Zhang
ac3951d731 test(plugins): cover before_install install flows 2026-03-29 12:35:01 -07:00
George Zhang
7cd9957f62 plugins: add before_install hook for install scanners 2026-03-29 12:35:01 -07:00
Robin Waslander
77555d6c85 fix(infra): classify SQLite transient errors as non-fatal in unhandled rejection handler (#57018)
Add isTransientSqliteError() covering SQLITE_CANTOPEN, SQLITE_BUSY,
SQLITE_LOCKED, and SQLITE_IOERR via named codes, numeric errcodes
(node:sqlite), and message-string fallback. Combine with existing
network transient check so both families are treated as non-fatal
in the global unhandled rejection handler.

Prevents crash loop under launchd on macOS when SQLite files are
temporarily unavailable.

Fixes #34678
2026-03-29 21:29:38 +02:00
Peter Steinberger
bfb0907777 fix: harden MCP SSE config redaction (#50396) (thanks @dhananjai1729) 2026-03-30 04:23:47 +09:00
dhananjai1729
2c6eb127d9 fix: redact sensitive query params in invalid URL error reasons
Extends the invalid-URL redaction to also scrub sensitive query parameters
(token, api_key, secret, access_token, etc.) using the same param list as
the valid-URL description path. Adds tests for both query param and
credential redaction in error reasons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
4e03d899b3 fix: handle Headers instances in SSE fetch and redact invalid URLs
- Properly convert Headers instances to plain objects in eventSourceInit.fetch
  so SDK-generated headers (e.g. Accept: text/event-stream) are preserved
  while user-configured headers still take precedence.
- Redact potential credentials from invalid URLs in error reasons to prevent
  secret leakage in log output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
62d0e12155 fix(mcp): user headers override SDK defaults & expand redaction list
Address Greptile P1/P2 review feedback:
- Fix header spread order so user-configured auth headers take precedence
  over SDK-internal headers in SSE eventSourceInit.fetch
- Add password, pass, auth, client_secret, refresh_token to the
  sensitive query-param redaction set in describeSseMcpServerLaunchConfig
- Add tests for redaction of all sensitive params and embedded credentials

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
32b7c00f90 fix: apply SSE auth headers to initial GET, redact URL credentials, warn on malformed headers 2026-03-30 04:23:47 +09:00
dhananjai1729
6fda8b4e9a fix: use SDK Transport type to satisfy client.connect() signature 2026-03-30 04:23:47 +09:00
dhananjai1729
bf8303370e fix: address review feedback - fix env JSDoc, warn on dropped headers, await server close 2026-03-30 04:23:47 +09:00
dhananjai1729
d89bfed5cc feat(mcp): add SSE transport support for remote MCP servers 2026-03-30 04:23:47 +09:00
Peter Steinberger
fc5fdcb091 refactor(plugin-sdk): remove bundled provider setup shims 2026-03-29 20:23:20 +01:00
George Zhang
e133924047 [codex] harden clawhub plugin publishing and install (#56870)
* fix: harden clawhub plugin publishing and install

* fix(process): preserve windows shim exit success
2026-03-29 11:59:19 -07:00
Peter Steinberger
58dde4b016 test(contracts): hoist shared plugin mock ids 2026-03-29 19:54:27 +01:00
Marcus Castro
34648235a3 WhatsApp: use shared resolveReactionMessageId for context-aware reactions (#57226)
Wire the shared resolveReactionMessageId helper into the WhatsApp
channel adapter, matching the pattern already used by Telegram, Signal,
and Discord. The model can now react to the current inbound message
without explicitly providing a messageId.

Safety guards:
- Only falls back to context when the source is WhatsApp
- Suppresses fallback when targeting a different chat (normalized comparison)
- Throws ToolInputError (400) instead of plain Error (500) when messageId
  is missing, preserving gateway error mapping
2026-03-29 15:42:19 -03:00
Nimrod Gutman
f38b7291f9 fix(ios): mark activitykit import as preconcurrency (#57180)
* fix(ios): mark activitykit import as preconcurrency

* fix: note iOS ActivityKit preconcurrency build fix (#57180) (thanks @ngutman)
2026-03-29 21:24:25 +03:00
Peter Steinberger
5801506ce7 test(discord): fix hoisted configured-binding mocks 2026-03-29 19:17:08 +01:00
Peter Steinberger
8a6d1b9f1e test(contracts): avoid sync telegram vitest harness loads 2026-03-29 18:37:11 +01:00
Tyler Yust
798e5f9501 plugin-sdk: fix provider setup import cycles 2026-03-29 09:59:52 -07:00
Peter Steinberger
56640a6725 fix(plugin-sdk): break vllm setup recursion 2026-03-29 17:55:09 +01:00
Keith Elliott
2d2e386b94 fix(matrix): resolve crypto bootstrap failure and multi-extension idHint warning (#53298)
Merged via squash.

Prepared head SHA: 6f5813ffff
Co-authored-by: keithce <2086282+keithce@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 12:38:10 -04:00
Peter Steinberger
ba7911bd16 ci: extend long-running test lane timeouts 2026-03-29 16:48:50 +01:00
Peter Steinberger
354bc01f29 ci: raise test workflow timeouts 2026-03-29 16:00:51 +01:00
Peter Steinberger
637b4c8193 refactor: move remaining provider policy into plugins 2026-03-29 23:05:58 +09:00
Peter Steinberger
edc58a6864 refactor: generalize provider transport hooks 2026-03-29 23:05:58 +09:00
Peter Steinberger
8109195ad8 fix(plugin-sdk): avoid recursive bundled facade loads 2026-03-29 15:00:25 +01:00
Peter Steinberger
24d16c39ad refactor(plugin-sdk): remove source alias residue 2026-03-29 14:53:03 +01:00
Peter Steinberger
e6116769b4 build(plugin-sdk): verify dist facade exports 2026-03-29 14:53:03 +01:00
Peter Steinberger
2c9bc0bb78 chore(deps): bump workspace dependencies 2026-03-29 14:41:58 +01:00
Peter Steinberger
2dd29db464 fix: ease bundled browser plugin recovery 2026-03-29 22:39:30 +09:00
Peter Steinberger
f1af7d66d2 chore: bump version to 2026.3.29 2026-03-29 14:33:12 +01:00
Thomas M
0a01386756 fix: canonicalize session keys at write time (#30654) (thanks @thomasxm)
* fix: canonicalize session keys at write time to prevent orphaned sessions (#29683)

resolveSessionKey() uses hardcoded DEFAULT_AGENT_ID="main", but all read
paths canonicalize via cfg. When the configured default agent differs
(e.g. "ops" with mainKey "work"), writes produce "agent:main:main" while
reads look up "agent:ops:work", orphaning transcripts on every restart.

Fix all three write-path call sites by wrapping with
canonicalizeMainSessionAlias:
- initSessionState (auto-reply/reply/session.ts)
- runWebHeartbeatOnce (web/auto-reply/heartbeat-runner.ts)
- resolveCronAgentSessionKey (cron/isolated-agent/session-key.ts)

Add startup migration (migrateOrphanedSessionKeys) to rename existing
orphaned keys to canonical form, merging by most-recent updatedAt.

* fix: address review — track agent IDs in migration map, align snapshot key

P1: migrateOrphanedSessionKeys now tracks agentId alongside each store
path in a Map instead of inferring from the filesystem path. This
correctly handles custom session.store templates outside the default
agents/<id>/ layout.

P2: Pass the already-canonicalized sessionKey to getSessionSnapshot so
the heartbeat snapshot reads/restores use the same key as the write path.

* fix: log migration results at all early return points

migrateOrphanedSessionKeys runs before detectLegacyStateMigrations, so
it can canonicalize legacy keys (e.g. "main" → "agent:main:main") before
the legacy detector sees them. This caused the early return path to skip
logging, breaking doctor-state-migrations tests that assert log.info was
called.

Extract logMigrationResults helper and call it at every return point.

* fix: handle shared stores and ~ expansion in migration

P1: When session.store has no {agentId}, all agents resolve to the same
file. Track all agentIds per store path (Map<path, Set<id>>) and run
canonicalization once per agent. Skip cross-agent "agent:main:*"
remapping when "main" is a legitimate configured agent sharing the store,
to avoid merging its data into another agent's namespace.

P2: Use expandHomePrefix (environment-aware ~ resolution) instead of
os.homedir() in resolveStorePathFromTemplate, matching the runtime
resolveStorePath behavior for OPENCLAW_HOME/HOME overrides.

* fix: narrow cross-agent remap to provable orphan aliases only

Only remap agent:main:* keys where the suffix is a main session alias
("main" or the configured mainKey). Other agent:main:* keys — hooks,
subagents, cron sessions, per-sender keys — may be intentional
cross-agent references and must not be silently moved into another
agent's namespace.

* fix: run orphan-key session migration at gateway startup (#29683)

* fix: canonicalize cross-agent legacy main aliases in session keys (#29683)

* fix: guard shared-store migration against cross-agent legacy alias remap (#29683)

* refactor: split session-key migration out of pr 30654

---------

Co-authored-by: Your Name <your_email@example.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 18:59:25 +05:30
Peter Steinberger
e0f0a1aa1f docs: clarify browser allowlist troubleshooting 2026-03-29 22:19:22 +09:00
wangchunyue
2c8c4e45f1 fix: preserve fallback prompt on model fallback for new sessions (#55632) (thanks @openperf)
* fix(agents): preserve original task prompt on model fallback for new sessions

* fix(agents): use dynamic transcript check for sessionHasHistory on fallback retry

Address Greptile review feedback: replace the static !isNewSession flag
with a dynamic sessionFileHasContent() check that reads the on-disk
transcript before each fallback retry. This correctly handles the edge
case where the primary model completes at least one assistant-response
turn (flushing the user message to disk) before failing - the fallback
now sends the recovery prompt instead of duplicating the original body.

The !isNewSession short-circuit is kept as a fast path so existing
sessions skip the file read entirely.

* fix(agents): address security vulnerabilities in session fallback logic

Fixes three medium-severity security issues identified by Aisle Security Analysis on PR #55632:
- CWE-400: Unbounded session transcript read in sessionFileHasContent()
- CWE-400: Symlink-following in sessionFileHasContent()
- CWE-201: Sensitive prompt replay to a different fallback provider

* fix(agents): use JSONL parsing for session history detection (CWE-703)

Replace bounded byte-prefix substring matching in sessionFileHasContent()
with line-by-line JSONL record parsing. The previous approach could miss
an assistant message when the preceding user content exceeded the 256KB
read limit, causing a false negative that blocks cross-provider fallback
entirely.

* fix(agents): preserve fallback prompt across providers

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 18:35:45 +05:30
wangchunyue
fc3f6fa51f fix: preserve node exec cwd on remote hosts (#50961) (thanks @openperf)
* fix(gateway): skip local workdir resolution for remote node execution

* chore: add inline comment for non-obvious node workdir skip

* fix: preserve node exec cwd on remote hosts (#50961) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 17:46:49 +05:30
Tak Hoffman
5f85c4e69f Docs: add runtime semantics guidance to AGENTS 2026-03-29 06:57:56 -05:00
Ayaan Zaidi
ee701d6bad build: bump Android version to 2026.3.29 2026-03-29 17:15:23 +05:30
Mariano
92d0b3a557 Gateway: abstract task registry storage (#56927)
Merged via squash.

Prepared head SHA: 8db9b860e8
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-29 12:58:06 +02:00
Mariano
17c36b5093 Gateway: track background task lifecycle (#52518)
Merged via squash.

Prepared head SHA: 7c4554204e
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-29 12:48:02 +02:00
Peter Steinberger
270d0c5158 fix: avoid telegram plugin self-recursive sdk imports 2026-03-29 11:32:29 +01:00
Luke
88ca0b2c3f fix(status): handle node-only hosts on current main (#56718)
* Status: handle node-only hosts

* Status: address follow-up review nits

* Changelog: note node-only status fix

* Status: lazy-load node-only helper
2026-03-29 21:12:08 +11:00
nanakotsai
571da81a35 fix: keep openai-codex on HTTP responses transport 2026-03-29 15:04:38 +05:30
Vincent Koc
e06069c8c2 fix(sandbox): add CJK fonts to browser image (#56905) 2026-03-29 18:21:51 +09:00
助爪
443295448c Track ACP sessions_spawn runs and emit ACP lifecycle events (#40885)
* Fix ACP sessions_spawn lifecycle tracking

* fix(tests): resolve leftover merge markers in sessions spawn lifecycle test

* fix(agents): clarify acp spawn cleanup semantics

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-29 18:20:10 +09:00
Vincent Koc
a7a89fb680 fix(ci): retry actionlint release downloads 2026-03-29 18:11:38 +09:00
xingjie zhou
81193336d0 fix(acpx): read ACPX_PINNED_VERSION from package.json instead of hard… (#49089)
* fix(acpx): read ACPX_PINNED_VERSION from package.json instead of hardcoding

The hardcoded ACPX_PINNED_VERSION ("0.1.16") falls out of sync with the bundled acpx version in package.json every release, causing ACP runtime to be marked unavailable due to version mismatch (see #43997).

* Validate and sanitize ACPX version retrieval

Add validation for acpx version from package.json
2026-03-29 18:05:55 +09:00
Frank Yang
5adc50ce6b docs(changelog): add slack status reactions entry 2026-03-29 16:57:47 +08:00
Vincent Koc
7c50138f62 fix(plugins): keep built cli metadata scans lightweight 2026-03-29 17:55:17 +09:00
Hsiao A
cea7162490 feat(slack): status reaction lifecycle for tool/thinking progress indicators (#56430)
Merged via squash.

Prepared head SHA: 1ba5df3e3b
Co-authored-by: hsiaoa <70124331+hsiaoa@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-29 16:49:53 +08:00
Vincent Koc
e28fdb08b8 docs: add LINE ACP support and plugin requireApproval hook docs
- LINE: document ACP conversation binding support (#45826)
- Plugins: document requireApproval in before_tool_call hook semantics (#55339)
2026-03-29 17:45:26 +09:00
Vincent Koc
2899ce5198 Update CHANGELOG.md 2026-03-29 17:42:04 +09:00
Vincent Koc
af694def5b fix(agents): fail closed on silent turns (#52593)
* fix(agents): fail closed on silent turns

* fix(agents): suppress all silent turn emissions

* fix(agents): pass silent turns into embedded subscribe
2026-03-29 17:40:20 +09:00
Vincent Koc
f897aba69a docs: add missing feature docs for Matrix E2EE thumbnails, LINE media, and CJK memory
- Matrix: note encrypted thumbnail behavior in E2EE rooms (#54711)
- LINE: add outbound media section for image/video/audio sends (#45826)
- Memory: document CJK trigram tokenization and chunk sizing
2026-03-29 17:26:02 +09:00
Vincent Koc
3aac43e30b docs: remove stale MiniMax M2.5 refs and add image generation docs
After the M2.7-only catalog trim (#54487), update 10 docs files:
- Replace removed M2.5/VL-01 model references across FAQ, wizard,
  config reference, local-models, and provider pages
- Make local-models guide model-agnostic (generic LM Studio placeholder)
- Add image-01 generation section to minimax.md
- Leave third-party catalogs (Synthetic, Venice) unchanged
2026-03-29 17:26:02 +09:00
Vincent Koc
57882f0351 fix(web-search): localize shared search cache (#54040)
* fix(web-search): localize shared search cache

* docs(changelog): note localized web search cache

* test(web-search): assert module-local cache behavior

* Update CHANGELOG.md
2026-03-29 17:25:07 +09:00
Vignesh Natarajan
4d54376483 Tests: stabilize shard-2 queue and channel state 2026-03-29 01:12:58 -07:00
Vignesh Natarajan
9c185faba9 Agents: cover subagent memory tool policy 2026-03-29 01:12:58 -07:00
factnest365-ops
6c85c82ba3 fix: allow memory_search and memory_get in sub-agent sessions
Remove memory_search and memory_get from SUBAGENT_TOOL_DENY_ALWAYS.

These are read-only tools with no side effects that are essential for
multi-agent setups relying on shared memory for context retrieval.

Rationale:
- Read-only tools (memory_search, memory_get) have no side effects and
  cannot modify state, send messages, or affect external systems
- Other read-only tools (read, web_search, web_fetch) are already
  available to sub-agents by default
- Multi-agent deployments with shared knowledge depend on memory tools
  for context retrieval
- The workaround (tools.subagents.tools.alsoAllow) works but requires
  manual configuration that contradicts memorySearch.enabled: true

Fixes #55385
2026-03-29 01:12:58 -07:00
Peter Steinberger
341e617c84 docs(plugins): refresh bundled plugin runtime docs 2026-03-29 09:10:39 +01:00
Peter Steinberger
caeeecf399 refactor(test): centralize bundled channel test roots 2026-03-29 09:10:39 +01:00
Peter Steinberger
8e0ab35b0e refactor(plugins): decouple bundled plugin runtime loading 2026-03-29 09:10:38 +01:00
Vincent Koc
1738d540f4 fix(gateway/auth): local trusted-proxy fallback to require token auth (#54536)
* fix(auth): improve local request and trusted proxy handling

* fix(gateway): require token for local trusted-proxy fallback

* docs(changelog): credit trusted-proxy auth fix

* Update src/gateway/auth.ts

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

* fix(gateway): fail closed on forwarded local detection

* docs(gateway): clarify fail-closed local detection

* fix(gateway): harden trusted-proxy local fallback

* fix(gateway): align trusted-proxy loopback validation

* Update CHANGELOG.md

---------

Co-authored-by: “zhangning” <zhangning.2025@bytedance.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-29 17:05:00 +09:00
Vincent Koc
9777781001 fix(subagents): preserve requester agent for inline announces (#55998)
* fix(subagents): preserve requester agent on inline announce

* docs(changelog): remove maintainer follow-up entry
2026-03-29 17:00:05 +09:00
Vincent Koc
d6a4ec6a3d fix(telegram): sanitize invalid stream-order errors (#55999)
* fix(telegram): sanitize invalid stream order errors

* docs(changelog): remove maintainer follow-up entry
2026-03-29 16:59:15 +09:00
Vincent Koc
aec58d4cde fix(agents): repair btw reasoning and oauth snapshot refresh (#56001)
* fix(agents): repair btw reasoning and oauth snapshot refresh

* Update CHANGELOG.md

* test(agents): strengthen btw reasoning assertion
2026-03-29 16:58:49 +09:00
Peter Steinberger
f4d60478c9 test: reset plugin runtime state in optional tools tests 2026-03-29 08:52:12 +01:00
Ted Li
ebb919e311 fix: prefer transcript model in sessions list (#55628) (thanks @MonkeyLeeT)
* gateway: prefer transcript model in sessions list

* gateway: keep live subagent model in session rows

* gateway: prefer selected model until runtime refresh

* gateway: simplify session model identity selection

* gateway: avoid transcript model fallback on cost-only reads
2026-03-29 13:20:55 +05:30
Vignesh Natarajan
08b5206b19 chore(test): harden channel plugin registry against malformed runtime state 2026-03-29 00:47:39 -07:00
Vignesh Natarajan
8bdb518bde Memory/LanceDB: fix bundled runtime manifest lookup (#56623) 2026-03-29 00:37:46 -07:00
Peter Steinberger
c48e0f8e6a style: normalize import order and formatting 2026-03-29 16:33:22 +09:00
Peter Steinberger
04c976b43d refactor(markdown): share render-aware chunking 2026-03-29 16:33:22 +09:00
Peter Steinberger
c664b67796 fix: apply Mistral compat across proxy transports 2026-03-29 16:32:31 +09:00
Ayaan Zaidi
4a5885df3a fix(imessage): try all inbound echo ids 2026-03-29 13:00:01 +05:30
GodsBoy
bc9c074b2c fix(channels): use pinned channel registry for outbound adapter resolution
loadChannelOutboundAdapter (via createChannelRegistryLoader) was reading
from getActivePluginRegistry() — the unpinned active registry that gets
replaced whenever loadOpenClawPlugins() runs (config schema reads, plugin
status queries, tool listings, etc.).

After replacement, the active registry may omit channel entries or carry
them in setup mode without outbound adapters, causing:

  Outbound not configured for channel: telegram

The channel inbound path already uses the pinned registry
(getActivePluginChannelRegistry) which is frozen at gateway startup and
survives all subsequent registry replacements. This commit aligns the
outbound path to use the same pinned surface.

Adds a regression test that pins a registry with a telegram outbound
adapter, replaces the active registry with an empty one, then asserts
loadChannelOutboundAdapter still resolves the adapter.

Fixes #54745
Fixes #54013
2026-03-29 12:54:14 +05:30
Rohan Marr
b29e180ef4 fix: prevent self-chat dedupe false positives (#55359) (thanks @rmarr)
* fix(imessage): prevent self-chat dedupe false positives (#47830)

Move echo cache remember() to post-send only, add early return when
inbound message ID doesn't match cached IDs (prevents text-based
false positives in self-chat), and reduce text TTL from 5s to 3s.

Three targeted changes to fix silent user message loss in self-chat:

1. deliver.ts: Remove pre-send remember() call — cache only reflects
   successfully-delivered content, not pre-send full text.

2. echo-cache.ts: Skip text fallback when inbound has a valid message ID
   that doesn't match any cached outbound ID. In self-chat, sender == target
   so scopes collide; a user message with a fresh ID but matching text was
   incorrectly dropped as an echo.

3. echo-cache.ts: Reduce text TTL from 5000ms to 3000ms — agent echoes
   arrive within 1-2s, 5s was too wide.

Adds self-chat-dedupe.test.ts (7 tests) + updates deliver.test.ts.
BlueBubbles uses a different cache pattern — no changes needed there.

Closes #47830

* review(imessage): strip debug logs, bump echo TTL to 4s (#47830)

Bruce Phase 4 review changes:
- Remove all [IMSG-DEBUG] console.error calls from inbound-processing.ts
  and monitor-provider.ts (23 lines, left over from Phase 2 debug deploy)
- Bump SENT_MESSAGE_TEXT_TTL_MS from 3s to 4s in echo-cache.ts to give
  ~2s margin above the observed 2.2s echo arrival time under load
- Update TTL tests to reflect 4s TTL (expired at 5s, live at 3s)

* fix(imessage): add dedupe comments and canary/compat/TTL tests

* fix(imessage): address review feedback on echo cache, shadowing, and test IDs

* refactor(imessage): hoist inboundMessageId to eliminate duplicate computation (#47830)

* fix(imessage): unify self-chat echo matching

* fix: use inbound guid for self-chat echo matching (#55359) (thanks @rmarr)

---------

Co-authored-by: Rohan Marr <rmarr@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 12:51:17 +05:30
Vignesh Natarajan
1a0c3bf400 Memory: fix FTS-only branch compile on rebased main 2026-03-29 00:09:33 -07:00
Vignesh Natarajan
598f539be5 Memory: keep FTS-only indexing on reindex (#42714) 2026-03-29 00:06:49 -07:00
opriz
41c30f0c59 fix: populate FTS-only memory search without provider (#56473) (thanks @opriz)
* fix(memory): build FTS index when no embedding provider is available

* fix(memory): trigger full reindex on provider→FTS-only transition

* fix(memory): return FTS-only keyword hits at default threshold

* fix: keep FTS-only memory hits at default threshold (#56473) (thanks @opriz)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 12:22:35 +05:30
Vignesh Natarajan
4b137da582 Test: harden command queue test isolation (CI chore) 2026-03-28 23:43:03 -07:00
Vignesh Natarajan
e816d0968a Status: suppress false dual-stack loopback port warning (#53398) 2026-03-28 23:25:02 -07:00
Gustavo Madeira Santana
c7330eb716 Docs: audit Matrix CLI and setup docs 2026-03-29 01:48:18 -04:00
Gustavo Madeira Santana
efa4e3d83e Docs: audit Matrix channel docs 2026-03-29 01:48:14 -04:00
gfzhx
d458e1d05c fix(discord): do not bypass requireMention for configuredBinding channels 2026-03-29 11:17:15 +05:30
Jakub Rusz
7e7e45c2f3 feat(matrix): add draft streaming (edit-in-place partial replies) (#56387)
Merged via squash.

Prepared head SHA: 53e566bf30
Co-authored-by: jrusz <55534579+jrusz@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 01:43:02 -04:00
wangchunyue
dd61171f5b fix: prevent Telegram polling watchdog from dropping replies (#56343) (thanks @openperf)
* fix(telegram): prevent polling watchdog from aborting in-flight message delivery

The polling-stall watchdog only tracked getUpdates timestamps to detect
network stalls. When the agent takes >90s to process a message (common
with local/large models), getUpdates naturally pauses, and the watchdog
misidentifies this as a stall. It then calls fetchAbortController.abort(),
which cancels all in-flight Telegram API requests — including the
sendMessage call delivering the agent's reply. The message is silently
lost with no retry.

Track a separate lastApiActivityAt timestamp that is updated whenever
any Telegram API call (sendMessage, sendChatAction, etc.) completes
successfully. The watchdog now only triggers when both getUpdates AND
all other API activity have been silent beyond the threshold, proving
the network is genuinely stalled rather than just busy processing.

Update existing stall test to account for the new timestamp, and add a
regression test verifying that recent sendMessage activity suppresses
the watchdog.

Fixes #56065
Related: #53374, #54708

* fix(telegram): guard watchdog against in-flight API calls

* fix(telegram): bound watchdog API liveness

* fix: track newest watchdog API activity (#56343) (thanks @openperf)

* fix: note Telegram watchdog delivery fix (#56343) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 11:11:28 +05:30
Shen Yiming
eee8e9679e fix(clawhub): sanitize archive temp filenames (openclaw#56779)
Verified:
- pnpm build
- pnpm check
- pnpm test

Co-authored-by: soimy <1550237+soimy@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-29 00:38:03 -05:00
Ayaan Zaidi
7a16a48198 fix: keep telegram plugin callbacks explicit 2026-03-29 10:56:36 +05:30
scoootscooob
5d81b64343 fix(exec): fail closed when sandbox is unavailable and harden deny followups (#56800)
* fix(exec): fail closed when sandbox is unavailable and harden deny followups

* docs(changelog): note exec fail-closed fix
2026-03-28 22:20:49 -07:00
Ayaan Zaidi
d5e59621a7 fix: keep telegram plugin fallback explicit (#45911) (thanks @suboss87) 2026-03-29 10:45:57 +05:30
Ayaan Zaidi
1791c7c304 fix: unify telegram exec approval auth 2026-03-29 10:45:57 +05:30
Subash Natarajan
aee7992629 fix(telegram): accept approval callbacks from forwarding target recipients
When approvals.exec.targets routes to a Telegram DM, the recipient
receives inline approval buttons but may not have explicit
channels.telegram.execApprovals configured. This adds a fallback
isTelegramExecApprovalTargetRecipient check so those DM recipients
can act on the buttons they were sent.

Includes accountId scoping for multi-bot deployments and 9 new tests.
2026-03-29 10:45:57 +05:30
Ayaan Zaidi
3a43401924 fix(ui): keep explicit ended steer targets 2026-03-29 10:27:07 +05:30
fuller-stack-dev
83808fe494 fix: wire control-ui steer and redirect (#54625) (thanks @fuller-stack-dev)
* feat(ui): wire /steer slash command to sessions.steer RPC

* feat(ui): wire /steer (soft inject) and /redirect (hard restart) slash commands

* test: use generic subagent names in steer/redirect tests

* fix(ui): exempt steer/redirect from busy-queue and guard sessions.list failures

* fix(ui): skip 'all' wildcard in steer/redirect target resolution

* test: register slash-command-executor test in vitest config

* fix(ui): restrict steer target to subagent keys and active sessions

Address two review issues in resolveSteerTarget:

P2: Replace resolveKillTargets with a dedicated resolveSteerSubagent
that matches only on subagent key suffix or label, not agent id.
This prevents false-positive targeting when the first word collides
with an agent id (e.g. "/steer main refine plan").

P1: Filter out ended sessions (endedAt set) so stale subagents with
reused names are not targeted.

* fix(ui): use shared generateUUID for steer idempotency key

* fix: restore telegram test to upstream state (merge artifact)

* fix(ui): track redirected run so Abort works and concurrent sends are blocked

* fix(ui): skip run tracking when /redirect targets a subagent session

* fix(ui): block idle steer runs

* fix(ui): dedupe steer slash command

* fix(ui): show pending steer state

* fix: wire control-ui steer and redirect (#54625) (thanks @fuller-stack-dev)

* fix: tighten steer target resolution (#54625) (thanks @fuller-stack-dev)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 10:15:58 +05:30
Ayaan Zaidi
e3faa99c6a fix(plugins): preserve gateway-bindable registry reuse
# Conflicts:
#	src/agents/runtime-plugins.test.ts
#	src/agents/runtime-plugins.ts
#	src/plugins/loader.ts
#	src/plugins/tools.ts
2026-03-29 09:59:06 +05:30
Vignesh Natarajan
bb9394e123 Changelog: note Control UI agents metadata/file fix 2026-03-28 21:22:59 -07:00
Vignesh Natarajan
5a0bd9036c Chore: regenerate protocol Swift agent summary models 2026-03-28 21:17:47 -07:00
Vignesh Natarajan
384a590e54 Agents UI: fix effective model and file hydration 2026-03-28 21:10:39 -07:00
Dewaldt Huysamen
27188fa39f fix: scope subagent registry reuse to tool loading (#56240) (thanks @GodsBoy)
* fix(plugins): reuse active registry for sub-agent tool resolution

* test(plugins): harden resolveRuntimePluginRegistry with per-field, caller-shape, and cold-start tests

Add 11 regression tests covering:
- R1: Per-field isolation (coreGatewayHandlers, includeSetupOnlyChannelPlugins,
  preferSetupRuntimeForChannelPlugins each independently prevent fallback;
  empty onlyPluginIds[] treated as non-gateway-scoped)
- R2: Caller-shape regression (tools.ts, memory-runtime.ts,
  channel-resolution.ts shapes fall back; web-search-providers.runtime.ts
  with onlyPluginIds does not)
- R3: Cold-start path (null active registry falls through to loadOpenClawPlugins)

Add debug logging to resolveRuntimePluginRegistry recording which exit path
was taken (no-options, cache-key-match, non-gateway-scoped fallback, fresh load).

* refactor: simplify plugin registry resolution tests and trim happy-path debug logs

* fix(plugins): address review comments on registry fallback

- Fix cold-start test assertion: loadOpenClawPlugins always activates
  the registry (shouldActivate defaults to true), so getActivePluginRegistry()
  is not null after the call. Updated assertion to match actual behavior.

- Add safety comment documenting why the non-gateway-scoped fallback is
  safe despite cache-key mismatch: single-gateway-per-process model means
  sub-agents share workspaceDir, config, and env with the gateway.

* test(plugins): restructure per-field isolation tests to avoid load timeouts

Test isGatewayScopedLoad directly instead of going through the full
resolveRuntimePluginRegistry path which triggers expensive plugin
discovery. This fixes the includeSetupOnlyChannelPlugins test timing
out in CI while providing more precise coverage of the predicate.

* fix(plugins): expand safety comment to address startup-scoped registry concern

* fix(plugins): scope subagent registry reuse to tool loading

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-29 09:36:09 +05:30
Vignesh Natarajan
6883f688e8 Docker setup: force BuildKit for local builds 2026-03-28 20:46:35 -07:00
yuanchao
ec7f19e2ef fix(kimi): preserve valid Anthropic-compatible toolCall arguments in malformed-args repair path (openclaw#54491)
Verified:
- pnpm build
- pnpm check
- pnpm test -- src/agents/pi-embedded-runner/run/attempt.test.ts

Co-authored-by: yuanaichi <7549002+yuanaichi@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-28 22:37:50 -05:00
Vignesh Natarajan
a2e4707cfe fix(agents): recover prefixed malformed tool-call JSON 2026-03-28 20:22:22 -07:00
Vignesh Natarajan
64da916590 fix(tui): stop hijacking j/k in model search 2026-03-28 19:48:00 -07:00
Tak Hoffman
dc64a86eb8 fix(tests): refresh mattermost monitor mocks 2026-03-28 21:37:22 -05:00
Vignesh Natarajan
61a0b02931 fix(tui): preserve optimistic user messages during active runs 2026-03-28 19:32:26 -07:00
Jackjin
a0407c7254 fix(line): preserve underscores inside words in stripMarkdown (#47465)
Fixes #46185.

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm test -- extensions/line/src/markdown-to-line.test.ts src/tts/prepare-text.test.ts

Note: `pnpm check` currently fails on unchanged `extensions/microsoft/speech-provider.test.ts` lines 108 and 139 on the rebased base, outside this PR diff.
2026-03-28 21:31:09 -05:00
Tak Hoffman
e61ffa68f1 fix(tests): refresh generated schema contracts 2026-03-28 21:19:11 -05:00
Edward-Qiang-2024
1c8758fbd5 Fix: Correctly estimate CJK character token count in context pruner (openclaw#39985)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test -- src/agents/pi-extensions/context-pruning.test.ts src/utils/cjk-chars.test.ts

Co-authored-by: Edward-Qiang-2024 <176464463+Edward-Qiang-2024@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-28 21:16:52 -05:00
Vignesh Natarajan
7cf87c4e53 chore(ci): refresh generated schema snapshots 2026-03-28 19:13:36 -07:00
Peter Steinberger
14832ff9f0 build: update appcast for 2026.3.28 2026-03-29 03:12:14 +01:00
Tak Hoffman
38d7e808d9 test: refresh bundled plugin metadata 2026-03-28 21:07:36 -05:00
Extra Small
69a0a0edc5 fix(tts): use Chinese voice for CJK text in edge-tts provider (openclaw#52355)
Verified:
- pnpm test -- extensions/microsoft/speech-provider.test.ts extensions/microsoft/tts.test.ts

Notes:
- Rebases and refactor-port completed onto current main.
- No required GitHub checks were reported for this branch at merge time.

Co-authored-by: Extra Small <littleshuai.bot@gmail.com>
2026-03-28 21:06:48 -05:00
Tak Hoffman
f1970b8aef fix(line): normalize canonical ACP targets (#56713) 2026-03-28 21:05:34 -05:00
Vignesh Natarajan
89881379dc fix(config): allow plugin channels in hooks mappings 2026-03-28 19:00:05 -07:00
Tak Hoffman
3ce48aff66 Memory: add configurable FTS5 tokenizer for CJK text support (openclaw#56707)
Verified:
- pnpm build
- pnpm check
- pnpm test -- extensions/memory-core/src/memory/manager-search.test.ts packages/memory-host-sdk/src/host/query-expansion.test.ts
- pnpm test -- extensions/memory-core/src/memory/index.test.ts -t "reindexes when extraPaths change"
- pnpm test -- src/config/schema.base.generated.test.ts
- pnpm test -- src/media-understanding/image.test.ts
- pnpm test

Co-authored-by: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com>
2026-03-28 20:53:29 -05:00
Tak Hoffman
6f7ff545dd fix(line): add ACP binding parity (#56700)
* fix(line): support ACP current-conversation binding

* fix(line): add ACP binding routing parity

* docs(changelog): note LINE ACP parity

* fix(line): accept canonical ACP binding targets
2026-03-28 20:52:31 -05:00
Masato Hoshino
9449e54f4f feat(line): add outbound media support for image, video, and audio
pnpm install --frozen-lockfile
pnpm build
pnpm check
pnpm vitest run extensions/line/src/channel.sendPayload.test.ts extensions/line/src/send.test.ts extensions/line/src/outbound-media.test.ts

Co-authored-by: masatohoshino <246810661+masatohoshino@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-28 20:51:16 -05:00
jnuyao
f93ccc3443 fix(ui): prevent marked from auto-linking adjacent CJK characters (openclaw#48410)
Verified:
- ui: pnpm test -- --run src/ui/markdown.test.ts
- local full gate relaxed for this run; no required GitHub checks reported on the branch

Co-authored-by: jnuyao <2928523+jnuyao@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-28 20:49:29 -05:00
Peter Steinberger
f9b1079283 build: cut 2026.3.28 stable 2026-03-29 02:33:41 +01:00
Peter Steinberger
584e627d77 docs: add changelog for CJK memory chunking (#40271) 2026-03-29 10:22:43 +09:00
AaronLuo00
f8547fcae4 fix: guard fine-split against breaking UTF-16 surrogate pairs
When re-splitting CJK-heavy segments at chunking.tokens, check whether the
slice boundary falls on a high surrogate (0xD800–0xDBFF) and if so extend
by one code unit to keep the pair intact.  Prevents producing broken
surrogate halves for CJK Extension B+ characters (U+20000+).

Add test verifying no lone surrogates appear when splitting lines of
surrogate-pair characters with an odd token budget.

Addresses third-round Codex P2 review comment.
2026-03-29 10:22:43 +09:00
AaronLuo00
3b95aa8804 fix: address second-round review — Latin backward compat and emoji consistency
- Two-pass line splitting: first slice at maxChars (unchanged for Latin),
  then re-split only CJK-heavy segments at chunking.tokens. This preserves
  the original ~800-char segments for ASCII lines while keeping CJK chunks
  within the token budget.

- Narrow surrogate-pair adjustment to CJK Extension B+ range (D840–D87E)
  only, so emoji surrogate pairs are not affected. Mixed CJK+emoji text
  is now handled consistently regardless of composition.

- Add tests: emoji handling (2), Latin backward-compat long-line (1).

Addresses Codex P1 (oversized CJK segments) and P2s (Latin over-splitting,
emoji surrogate inconsistency).
2026-03-29 10:22:43 +09:00
AaronLuo00
a5147d4d88 fix: address bot review — surrogate-pair counting and CJK line splitting
- Use code-point length instead of UTF-16 length in estimateStringChars()
  so that CJK Extension B+ surrogate pairs (U+20000+) are counted as 1
  character, not 2 (fixes ~25% overestimate for rare characters).

- Change long-line split step from maxChars to chunking.tokens so that
  CJK lines are sliced into token-budget-sized segments instead of
  char-budget-sized segments that produce ~4x oversized chunks.

- Add tests for both fixes: surrogate-pair handling and long CJK line
  splitting.

Addresses review feedback from Greptile and Codex bots.
2026-03-29 10:22:43 +09:00
AaronLuo00
971ecabe80 fix(memory): account for CJK characters in QMD memory chunking
The QMD memory system uses a fixed 4:1 chars-to-tokens ratio for chunk
sizing, which severely underestimates CJK (Chinese/Japanese/Korean) text
where each character is roughly 1 token. This causes oversized chunks for
CJK users, degrading vector search quality and wasting context window space.

Changes:
- Add shared src/utils/cjk-chars.ts module with CJK-aware character
  counting (estimateStringChars) and token estimation helpers
- Update chunkMarkdown() in src/memory/internal.ts to use weighted
  character lengths for chunk boundary decisions and overlap calculation
- Replace hardcoded estimateTokensFromChars in the context report
  command with the shared utility
- Add 13 unit tests for the CJK estimation module and 5 new tests for
  CJK-aware memory chunking behavior

Backward compatible: pure ASCII/Latin text behavior is unchanged.

Closes #39965
Related: #40216
2026-03-29 10:22:43 +09:00
Vignesh Natarajan
7f46b03de0 fix: keep memory flush daily files append-only (#53725) (thanks @HPluseven) 2026-03-28 18:22:11 -07:00
Vignesh Natarajan
9d1498b2c2 Agents: add memory flush append regression 2026-03-28 18:22:11 -07:00
HPluseven
60b7613156 Agents: forward memory flush append guard 2026-03-28 18:22:11 -07:00
Vignesh Natarajan
e2d0b7c583 chore(test): harden mattermost slash-http module mocks 2026-03-28 18:21:19 -07:00
Peter Steinberger
72de33c976 chore: refresh plugin sdk api baseline 2026-03-29 02:16:37 +01:00
Peter Steinberger
148a65fe90 refactor: share webhook channel status helpers 2026-03-29 02:11:22 +01:00
Gustavo Madeira Santana
2afc655bd5 ACP: document Matrix bind-here support 2026-03-28 21:07:58 -04:00
Vignesh Natarajan
19e52a1ba2 fix(memory/qmd): honor embedInterval independent of update interval 2026-03-28 18:05:05 -07:00
karesansui
acbdafc4f4 fix: propagate webhook mode to health monitor snapshot
Webhook channels (LINE, Zalo, Nextcloud Talk, BlueBubbles) are
incorrectly flagged as stale-socket during quiet periods because
snapshot.mode is always undefined, making the mode !== "webhook"
guard in evaluateChannelHealth dead code.

Add mode: "webhook" to each webhook plugin's describeAccount and
propagate described.mode in getRuntimeSnapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 02:01:25 +01:00
Peter Steinberger
acca306665 fix: polish LINE status snapshot checks (#45701) (thanks @tamaosamu) 2026-03-29 00:57:04 +00:00
劉超
03941e2dbf fix(line): use configured field in collectStatusIssues instead of raw token
collectStatusIssues previously checked account.channelAccessToken directly,
but this field is stripped by projectSafeChannelAccountSnapshotFields for
security. This caused 'openclaw status' to always report WARN even when the
token is valid and the LINE provider starts successfully.

Use account.configured instead, which is already computed by
buildChannelAccountSnapshot and correctly reflects whether credentials
are present. This is consistent with how other channels (e.g. Telegram)
implement their status checks.

Fixes #45693
2026-03-29 00:57:04 +00:00
Peter Steinberger
b23ed7530b chore: ignore local tmp workspace 2026-03-29 00:51:03 +00:00
Peter Steinberger
5ebccf5e30 test: harden zalo webhook lifecycle tests 2026-03-29 00:48:02 +00:00
Peter Steinberger
9e1b524a00 fix: break mattermost runtime cycle 2026-03-29 00:43:58 +00:00
Peter Steinberger
fcc9fd1623 fix: land LINE timing-safe signature validation (#55663) (thanks @gavyngong) 2026-03-29 00:43:17 +00:00
gavyngong
7626d18c64 fix(line): eliminate timing side-channel in HMAC signature validation
Pad both buffers to equal length before constant-time comparison.

Key fix: call timingSafeEqual unconditionally and store the result
before the && length check, ensuring the constant-time comparison
always runs regardless of buffer lengths. This avoids JavaScript's
&& short-circuit evaluation which would skip timingSafeEqual on
length mismatches, preserving the timing side-channel.

Changes:
- Pad hash and signature buffers to maxLen before comparison
- Store timingSafeEqual result before combining with length check
- Add explanatory comment about the short-circuit avoidance
2026-03-29 00:43:17 +00:00
Peter Steinberger
92fb0caf35 fix: harden mac gateway attach smoke 2026-03-29 00:35:40 +00:00
Peter Steinberger
f9281d1b9d build: bump aws sdk and tsdown 2026-03-29 00:35:15 +00:00
Gustavo Madeira Santana
225d58b74a test: ignore stale isolated state dir in live env staging 2026-03-28 20:35:07 -04:00
Peter Steinberger
0e0945c5ed docs: reorder unreleased changelog fixes 2026-03-29 00:29:21 +00:00
Peter Steinberger
5cfb979766 docs: add missing CLAUDE symlinks 2026-03-29 09:29:04 +09:00
Peter Steinberger
5efed49208 fix: keep mac local gateway attached 2026-03-29 00:28:32 +00:00
Peter Steinberger
c9f1506d2f docs(xai): clarify x_search onboarding flow 2026-03-29 00:25:18 +00:00
Harold Hunt
fcee6fa047 Docs: add boundary AGENTS guides (#56647) 2026-03-28 20:22:03 -04:00
Peter Steinberger
03826b8075 fix(test): harden planner artifact cleanup and profile env fallback 2026-03-29 00:20:19 +00:00
Vignesh Natarajan
c3a0304f63 chore(test): fix stale web search audit coverage 2026-03-28 17:18:57 -07:00
Peter Steinberger
3d69ad8308 fix: preserve Teams Entra JWT fallback on legacy validator errors 2026-03-29 09:15:13 +09:00
Peter Steinberger
5872f860c9 feat(xai): add plugin-owned x_search onboarding 2026-03-29 00:12:37 +00:00
Gustavo Madeira Santana
ebb4794952 Tests: reuse paired provider contract aliases 2026-03-28 20:08:38 -04:00
Gustavo Madeira Santana
680c30bc5d Tests: shim config runtime for capability contracts 2026-03-28 20:02:28 -04:00
Peter Steinberger
3cbd3960f9 test: add minimax parallels smoke lane 2026-03-29 00:01:59 +00:00
Gustavo Madeira Santana
d0e0150129 Tests: retry scoped contract registry loads 2026-03-28 19:53:21 -04:00
Gustavo Madeira Santana
d3673fd53e Tests: isolate xAI contract lanes 2026-03-28 19:36:53 -04:00
Vignesh Natarajan
4e74e7e26c fix(memory): resolve slugified qmd search paths (#50313) 2026-03-28 16:26:38 -07:00
Gustavo Madeira Santana
5289e8f0fe Tests: lazy-load web search contract registries 2026-03-28 19:24:38 -04:00
Robin Waslander
3847ace25b fix(telegram): preserve forum topic routing for /new and /reset (#56654)
Build a topic-qualified routing target (telegram:<chatId>:topic:<threadId>)
for native commands in forum groups so /new and /reset stay scoped to
the active topic instead of falling back to General.

General topic (threadId=1) correctly falls through to the base chat
target since Telegram rejects message_thread_id=1 on sends.

Add regression tests for topic routing and General topic edge case.

Fixes #35963
2026-03-29 00:21:41 +01:00
Gustavo Madeira Santana
094524a549 Types: tighten Teams validator return type 2026-03-28 19:15:27 -04:00
Gustavo Madeira Santana
bd1c48e4d9 Tests: lazy-load extension contract registries 2026-03-28 19:09:49 -04:00
Brad Groux
dc382b09be fix(msteams): accept strict Bot Framework and Entra service tokens (#56631)
* msteams: log policy-based inbound drops at info level

* fix(msteams): validate Bot Framework and Entra service token issuers

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-03-28 18:04:00 -05:00
Gustavo Madeira Santana
0b8bc0e1b4 Tests: cap CI extension batch concurrency 2026-03-28 18:58:47 -04:00
frischeDaten
81432d6b7e fix(matrix): encrypt thumbnails in E2EE rooms using thumbnail_file (#54711)
Merged via squash.

Prepared head SHA: 92be0e1ac2
Co-authored-by: frischeDaten <5878058+frischeDaten@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-28 18:41:23 -04:00
Robin Waslander
468185d1b5 fix(agents): handle unhandled stop reasons gracefully instead of crashing (#56639)
Wrap the embedded agent stream to catch 'Unhandled stop reason: ...'
errors from the provider adapter and convert them into structured
assistant error messages instead of crashing the agent run.

Covers all unknown stop reasons so future provider additions don't
crash the runner. The wrapper becomes a harmless no-op once the
upstream dependency handles them natively.

Fixes #43607
2026-03-28 23:35:12 +01:00
Peter Steinberger
664680318e fix(release): validate built tarballs in workflows 2026-03-28 22:33:24 +00:00
Peter Steinberger
1249dad6c4 fix(release): skip lifecycle scripts in npm precheck 2026-03-28 22:29:47 +00:00
Peter Steinberger
587e18cd3f chore: prepare 2026.3.28-beta.1 release 2026-03-28 22:24:51 +00:00
Peter Steinberger
143fb34bf9 docs: remove stale code_execution secretref path 2026-03-28 22:10:22 +00:00
Peter Steinberger
45ecf5e2e9 fix(xai): narrow code execution config typing 2026-03-28 22:10:22 +00:00
Peter Steinberger
8a24cbf450 chore: bump version to 2026.3.28 2026-03-28 22:05:21 +00:00
OfflynAI
cb00d44ae4 fix(matrix): load crypto-nodejs via createRequire to fix __dirname in ESM (#54566)
Merged via squash.

Prepared head SHA: 61d99628f9
Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-28 17:43:44 -04:00
Robin Waslander
2022dfd0a1 chore: backfill changelog entries for recent fixes (#56625)
Add missing changelog entries for PRs #56500, #56540, #56555, #56567,
#56573, #56587, #56595, #56612, #56620.
2026-03-28 22:42:27 +01:00
huntharo
216796f1e3 fix(xai): wire plugin-owned codeExecution config 2026-03-28 21:35:13 +00:00
huntharo
b7ab0ddb55 refactor(xai): move code_execution into plugin 2026-03-28 21:35:13 +00:00
Peter Steinberger
1617e0218f test(xai): add live x_search coverage 2026-03-28 21:35:13 +00:00
Peter Steinberger
6e0b67a2fd docs(secrets): sync credential surface matrix 2026-03-28 21:35:13 +00:00
Peter Steinberger
887d7584d6 refactor(plugins): expose bundled onboard helpers 2026-03-28 21:35:13 +00:00
Peter Steinberger
46c2928234 fix(plugins): stabilize provider contract loading 2026-03-28 21:35:13 +00:00
Peter Steinberger
dba1b31243 fix(xai): repair extension test boundaries 2026-03-28 21:35:13 +00:00
Peter Steinberger
1e424990a2 fix(xai): restore config-backed auth discovery 2026-03-28 21:35:13 +00:00
Peter Steinberger
2a950157b1 refactor(xai): move x_search into plugin 2026-03-28 21:35:13 +00:00
huntharo
396bf20cc6 Tools: add xAI-backed code_execution 2026-03-28 21:35:13 +00:00
huntharo
1c9684608a Docs: guide x_search toward exact-post stats lookups 2026-03-28 21:35:13 +00:00
huntharo
c8ed1638ea xAI: restore generic auth and x_search seams 2026-03-28 21:35:13 +00:00
huntharo
43143486eb Docs: refresh x_search secretref matrix 2026-03-28 21:35:13 +00:00
huntharo
0391e455bf Lint: drop stale model compat imports 2026-03-28 21:35:13 +00:00
huntharo
92fb4ad233 xAI: route x_search through public api seam 2026-03-28 21:35:13 +00:00
huntharo
4f3009f57e Tests: classify x_search secret target parity 2026-03-28 21:35:13 +00:00
huntharo
6e3b54430c Tests: keep extension onboarding coverage under extensions 2026-03-28 21:35:13 +00:00
huntharo
09e2ef965b Tests: fix rebased auth and runner type coverage 2026-03-28 21:35:13 +00:00
huntharo
b22f65992e Build: fix rebased provider secrets helper 2026-03-28 21:35:13 +00:00
huntharo
b918568b1e Rebase: reconcile xAI post-main conflicts 2026-03-28 21:35:13 +00:00
huntharo
fb989f0402 Tests: restore provider runtime contract wrapper 2026-03-28 21:35:13 +00:00
huntharo
df61660a26 xAI: centralize fallback auth resolution 2026-03-28 21:35:13 +00:00
huntharo
9dd08a49a4 xAI: reuse fallback auth for runtime and discovery 2026-03-28 21:35:13 +00:00
huntharo
800042a3d5 xAI: reuse plugin key for x_search 2026-03-28 21:35:13 +00:00
huntharo
8ca3710b90 xAI: strip unsupported payload fields 2026-03-28 21:35:13 +00:00
huntharo
fd748171b8 xAI: strip unsupported Responses reasoning params 2026-03-28 21:35:13 +00:00
huntharo
80a1ccc552 xAI: preserve session auth in embedded runs 2026-03-28 21:35:13 +00:00
huntharo
2765fdc2dd xAI: normalize stale Grok transport to Responses 2026-03-28 21:35:13 +00:00
huntharo
f0ce658fbb xAI: add auth resolution diagnostics 2026-03-28 21:35:13 +00:00
huntharo
d5fafbe3ce xAI: honor config-backed auth during provider bootstrap 2026-03-28 21:35:13 +00:00
huntharo
2d919cf63d xAI: reuse web search key for provider auth 2026-03-28 21:35:13 +00:00
huntharo
38e4b77e60 Tools: add x_search via xAI Responses 2026-03-28 21:35:13 +00:00
huntharo
5ed8ee6832 xAI: switch bundled provider defaults to Responses 2026-03-28 21:35:13 +00:00
Robin Waslander
4d6c8edd74 fix(telegram): skip empty text replies instead of crashing with GrammyError 400 (#56620)
Filter whitespace-only text chunks at the bot delivery fan-in before
they reach sendTelegramText(). Covers normal text replies, follow-up
text, and voice fallback text paths.

Media-only replies are unaffected. message_sent hook still fires with
success: false for suppressed empty replies.

Fixes #37278
2026-03-28 22:27:56 +01:00
Peter Steinberger
eec290e68d fix: support anthropic parallels smoke lanes 2026-03-28 21:27:39 +00:00
Devin Robison
703e68a749 Fix HTTP OpenAI-compatible routes missing operator.write scope checks (#56618)
* Fix HTTP OpenAI-compatible routes missing operator.write scope checks

* Update src/gateway/http-endpoint-helpers.ts

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

* Address Greptile feedback

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-28 15:22:21 -06:00
Robin Waslander
17479ceb43 fix(auto-reply): suppress JSON-wrapped NO_REPLY payloads before channel delivery (#56612)
Add shared isSilentReplyPayloadText() detector that catches both bare
NO_REPLY tokens and JSON {"action":"NO_REPLY"} envelopes. Apply at the
reply directive parser, reply normalizer, and embedded agent payload
builder so the control payload is stripped before any channel sees it.

Preserves media when text is only a silent control envelope.

Fixes #37727
2026-03-28 22:07:24 +01:00
Robin Waslander
ab2ef7bbfc fix(telegram): split long messages at word boundaries instead of mid-word (#56595)
Replace proportional text estimate with binary search for the largest
text prefix whose rendered Telegram HTML fits the character limit, then
split at the last whitespace boundary within that verified prefix.

Single words longer than the limit still hard-split (unavoidable).
Markdown formatting stays balanced across split points.

Fixes #36644
2026-03-28 21:24:59 +01:00
Robin Waslander
865160e572 fix(telegram): validate replyToMessageId before sending to Telegram API (#56587)
Add shared normalizeTelegramReplyToMessageId() that rejects non-numeric,
NaN, and mixed-content strings before they reach the Telegram Bot API.
Apply at all four API sinks: direct send, bot delivery, draft stream,
and bot helpers.

Prevents GrammyError 400 when non-numeric values from session metadata
slip through typed boundaries.

Fixes #37222
2026-03-28 20:47:10 +01:00
Robin Waslander
e69ea1acb3 fix(bluebubbles): guard debounce flush against null text (#56573)
Sanitize message text at the debounce enqueue boundary and add an
independent guard in combineDebounceEntries(). Prevents TypeError when
a queued entry has null text that reaches .trim() during flush.

Add regression test: enqueue null-text entry alongside valid message,
verify flush completes without error and valid message is delivered.

Fixes #35777
2026-03-28 20:22:05 +01:00
Peter Steinberger
756df2e955 test: tune gateway live probe skips 2026-03-28 19:13:47 +00:00
Peter Steinberger
914becee52 fix: isolate live test home from real config 2026-03-28 19:06:59 +00:00
Peter Steinberger
8ea4c4a6ba fix: tolerate npm stderr in Windows Parallels update smoke 2026-03-28 18:59:17 +00:00
Robin Waslander
d1b0f8e8e2 fix(google): resolve Gemini 3.1 models for all Google provider aliases (#56567)
The forward-compat resolver hardcoded 'google' as the provider ID for
template lookup, so alias providers (google-vertex, google-gemini-cli)
could not find matching templates. Pass the actual provider ID from the
runtime context and add a templateProviderId fallback for cross-provider
template resolution.

Also fix flash-lite prefix ordering — check 'gemini-3.1-flash-lite'
before 'gemini-3.1-flash' to prevent misclassification.

Add regression tests for pro, flash, and flash-lite across provider
aliases.

Fixes #36111
2026-03-28 19:59:14 +01:00
Robin Waslander
6be14ab388 fix(cli): defer zsh compdef registration until compinit is available (#56555)
The generated zsh completion script called compdef at source time,
which fails with 'command not found: compdef' when loaded before
compinit. Replace with a deferred registration that tries immediately,
and if compdef is not yet available, queues a self-removing precmd hook
that retries on first prompt.

Handles repeated sourcing (deduped hook entry) and shells that never
run compinit (completion simply never registers, matching zsh model).

Add real zsh integration test verifying no compdef error on source and
successful registration after compinit.

Fixes #14289
2026-03-28 19:35:32 +01:00
Tak Hoffman
f32f7d0809 Improve dashboard setup command copy UX (#56551) 2026-03-28 13:09:22 -05:00
Robin Waslander
31112d5985 fix(security): audit web search keys for all bundled providers (#56540)
hasWebSearchKey() was hardcoded to only check Brave and Perplexity
credentials. Replace with provider-aware check using
resolveBundledPluginWebSearchProviders() so Gemini, Grok/XAI, Kimi,
Moonshot, and OpenRouter credentials are recognized by the audit.

Add focused regression tests for each provider.

Fixes #34509
2026-03-28 18:55:38 +01:00
Peter Steinberger
02d4c1f2c3 refactor: derive channel metadata from plugin manifests 2026-03-28 17:17:10 +00:00
Frank Yang
c14b169a1b fix(acp): repair stale bindings after runtime exits (#56476)
* fix(acp): repair stale bindings after runtime exits

* fix(acp): narrow stale binding recovery

* fix(acp): preserve policy gating for stale sessions

* fix(acp): handle signal exits and canonical unbinds

* fix(acp): harden canonical stale-session recovery
2026-03-29 01:15:16 +08:00
Peter Steinberger
22de54d83d test: handle live model probe edge cases 2026-03-28 17:12:09 +00:00
Peter Steinberger
5194cf2019 refactor: load bundled provider catalogs dynamically 2026-03-28 16:57:36 +00:00
Tak Hoffman
54313a8730 fix(dev): rebuild dist after HEAD changes (#56510) 2026-03-28 11:49:09 -05:00
Robin Waslander
840b806c2f fix(docs): remove broken Xfinity SSL troubleshooting links from FAQ (#56500)
Remove circular self-link in English FAQ and dead anchor reference in
zh-CN FAQ. Both FAQ sections already contain the full workaround inline,
so the cross-references added no value and were never backed by a valid
target in troubleshooting.md.

Fixes #36970
2026-03-28 17:18:26 +01:00
Tak Hoffman
7a878164b0 ci: align bun shard counts with windows (#56429)
* ci: align bun shard counts with windows

* ci: retrigger stuck windows shard
2026-03-28 09:36:59 -05:00
Peter Steinberger
23772bb785 test: exclude topology fixtures from vitest collection 2026-03-28 13:49:16 +00:00
Peter Steinberger
f3ecd9ca9c test: guard ui session storage access in node runs 2026-03-28 13:38:21 +00:00
Tak Hoffman
3a34e6b65d Add reusable TypeScript topology analyzer for public surface usage 2026-03-28 08:37:26 -05:00
Peter Steinberger
5302aa8947 test: use safe storage helpers in app mount hooks 2026-03-28 13:24:04 +00:00
Saurabh Mishra
90e82fabb3 fix: display model name instead of ID in Telegram model selector (#56165) (#56175)
* fix: display model name instead of ID in Telegram model selector (#56165)

* fix(telegram): scope model display names by provider

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-28 09:23:09 -04:00
Peter Steinberger
e999f2aae3 test: silence lit dev-mode warnings in ui suite 2026-03-28 13:13:02 +00:00
Peter Steinberger
8c4cc61656 test: avoid raw localStorage access in chat view test 2026-03-28 13:10:27 +00:00
Peter Steinberger
bccbfdebfe fix: hydrate lazy tts provider config from source config 2026-03-28 12:56:27 +00:00
Peter Steinberger
3bb199aa43 refactor: lazy-load matrix setup bootstrap surfaces 2026-03-28 12:46:54 +00:00
Peter Steinberger
5df53a99b1 fix: set localstorage file for test planner workers 2026-03-28 12:46:54 +00:00
Tyler Yust
41cf93efff fix: include extension channels in subagent announce delivery path (#56348)
* fix: include extension channels in subagent announce delivery path

* test: cover extension announce delivery routes
2026-03-28 21:15:23 +09:00
Peter Steinberger
107969c725 test: silence warning filter stderr 2026-03-28 11:57:27 +00:00
Peter Steinberger
9b0b962f8c test: silence ui localstorage warning 2026-03-28 11:54:51 +00:00
Peter Steinberger
4757c32f63 test: silence planner fixture stderr 2026-03-28 11:53:14 +00:00
Peter Steinberger
241748ae60 test: align code region fence slices 2026-03-28 11:48:13 +00:00
Peter Steinberger
aa9454f270 fix: restore xai pricing cache fallback 2026-03-28 11:43:12 +00:00
Peter Steinberger
8061b792b2 test: repair focused unit lane drift 2026-03-28 11:41:06 +00:00
Peter Steinberger
aa33d585be fix: repair package contract and boundary drift 2026-03-28 11:40:40 +00:00
Peter Steinberger
f44d68a4f4 test: stabilize model auth label mocks 2026-03-28 11:40:40 +00:00
Peter Steinberger
c5a48a8c8a test: cover oauth profile store migration 2026-03-28 11:40:40 +00:00
Peter Steinberger
1c5a4d2a2b fix: stabilize implicit provider discovery merges 2026-03-28 11:40:40 +00:00
Peter Steinberger
e34a770b8a fix: keep provider discovery on mockable lazy runtime paths 2026-03-28 11:40:40 +00:00
Peter Steinberger
ff01d749fc fix: keep provider normalization on local sync paths 2026-03-28 11:40:13 +00:00
Peter Steinberger
cec1703734 fix: keep model selection on local normalization paths 2026-03-28 11:40:13 +00:00
Peter Steinberger
c1ae49e306 fix: keep cost lookup on sync pricing paths 2026-03-28 11:40:13 +00:00
Peter Steinberger
dec91c400d fix: keep status display on sync model metadata 2026-03-28 11:37:43 +00:00
Peter Steinberger
84d1781a3a fix: avoid status-time provider normalization recursion 2026-03-28 11:35:33 +00:00
Peter Steinberger
030d2e8b71 test: fix tts status helper temp-home prefs path 2026-03-28 11:35:33 +00:00
Peter Steinberger
0e11072b84 fix: avoid speech runtime import in status output 2026-03-28 11:35:33 +00:00
Peter Steinberger
85b3c1db30 fix: defer tts provider resolution until needed 2026-03-28 11:35:33 +00:00
Peter Steinberger
86dba6d906 fix: skip speech provider discovery on tts off path 2026-03-28 11:35:33 +00:00
Ayaan Zaidi
cfba0ab68f fix(process): wait for windows close state settlement 2026-03-28 16:55:15 +05:30
Ayaan Zaidi
0ebd7df9dc test(feishu): stabilize bot-menu lifecycle replay 2026-03-28 16:46:21 +05:30
Ayaan Zaidi
c3c1f9df54 fix(process): wait for windows exit code settlement 2026-03-28 16:37:29 +05:30
nikus-pan
bef4fa55f5 fix(model-fallback): add HTTP 410 to failover reason classification (#55201)
Merged via squash.

Prepared head SHA: 9c1780b739
Co-authored-by: nikus-pan <71585761+nikus-pan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-28 14:03:20 +03:00
Ayaan Zaidi
b31cd35b36 test(plugin-sdk): align matrix runtime api guardrail 2026-03-28 16:20:33 +05:30
Ayaan Zaidi
8d8652257c test(providers): align active registry expectations 2026-03-28 16:15:56 +05:30
Ayaan Zaidi
c06dcf6b8b fix(plugins): preserve active capability providers 2026-03-28 15:46:24 +05:30
Ayaan Zaidi
8ea5c22985 fix(matrix): avoid heavy jiti runtime barrels 2026-03-28 15:35:05 +05:30
Ayaan Zaidi
a628d5d78b fix(irc): pin runtime barrel exports for jiti 2026-03-28 15:23:22 +05:30
Ayaan Zaidi
3145757f8f test: make minimax image path batch-stable 2026-03-28 15:07:53 +05:30
Peter Steinberger
6f6b55c072 fix: stabilize provider sdk runtime surfaces 2026-03-28 09:35:42 +00:00
Peter Steinberger
a955537a61 fix: slim provider sdk surfaces 2026-03-28 09:35:42 +00:00
Peter Steinberger
2212bd0d4a test: align runtime registry fixtures 2026-03-28 09:35:42 +00:00
Peter Steinberger
1d6ba41762 test: stabilize snapshot and typing helpers 2026-03-28 09:35:42 +00:00
Ayaan Zaidi
20aba8c518 fix(ci): restore extension test runtime deps and update voice-call expectations 2026-03-28 15:04:33 +05:30
Ayaan Zaidi
40a09cc582 fix(irc): avoid registry bootstrap in plugin sdk seam 2026-03-28 14:58:02 +05:30
Ayaan Zaidi
e0ba57e9c7 fix: preserve windows child exit codes 2026-03-28 14:55:20 +05:30
Ayaan Zaidi
7320973ab0 fix(provider-wizard): avoid hook-time model normalization 2026-03-28 14:46:12 +05:30
Ayaan Zaidi
ced88298d8 test: make media runtime seam mock bun-safe 2026-03-28 14:39:47 +05:30
Ayaan Zaidi
28074eeea3 fix: dedupe plugin sdk file-lock export 2026-03-28 14:35:24 +05:30
Mariano
0afd73c975 fix(daemon): surface probe close reasons (#56282)
Merged via squash.

Prepared head SHA: c356980aa4
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-28 10:04:56 +01:00
Ayaan Zaidi
a68bef42eb test: align code region fence slices 2026-03-28 14:34:01 +05:30
Ayaan Zaidi
5d3104e699 fix: regenerate swift protocol models 2026-03-28 14:29:41 +05:30
Kenny Xie
cb5afdf108 fix: prevent matrix-js-sdk plugin load crash (#56273) (thanks @aquaright1)
* Fix matrix-js-sdk multiple entrypoint crash on plugin load

* test(matrix): cover runtime bundle import regression

* fix: prevent matrix-js-sdk plugin load crash (#56273) (thanks @aquaright1)

* fix: widen matrix-js-sdk bundle import guard (#56273) (thanks @aquaright1)

---------

Co-authored-by: Kenny Xie <kennyxie@Mac-mini-von-Kenny.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-28 14:27:54 +05:30
Ayaan Zaidi
3c0cf26e16 test: align media runtime registry seam 2026-03-28 14:21:17 +05:30
Ayaan Zaidi
5f82741d0f test: align telegram thread binding seam coverage 2026-03-28 14:17:09 +05:30
Ayaan Zaidi
4ef2615d7d test: fix bedrock discovery seam typing 2026-03-28 14:09:25 +05:30
Lakshya Agarwal
4dfd2cd60c feat: add support for extra headers in Tavily API requests (#55335)
* feat: add support for extra headers in Tavily API requests

* test(tavily-client): add unit tests for X-Client-Source header in API calls

* fix(tavily): add client source attribution (#55335) (thanks @lakshyaag-tavily)

---------

Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
2026-03-28 11:36:59 +03:00
Ayaan Zaidi
6777764a6b test: use bedrock plugin sdk seam in discovery tests 2026-03-28 14:05:49 +05:30
Peter Steinberger
a631604247 fix(ci): stabilize browser bundled integration tests 2026-03-28 08:34:18 +00:00
Peter Steinberger
a735a1a2d4 fix(text): handle fenced code fence termination 2026-03-28 08:34:18 +00:00
Ayaan Zaidi
c0a56ac1a1 test: use suite gateway hooks for channel mcp 2026-03-28 13:52:59 +05:30
Ayaan Zaidi
5224c5bbd5 test: isolate usage pricing cache state 2026-03-28 13:48:58 +05:30
Peter Steinberger
1c833b1eb5 test: align outbound telegram bootstrap mocks 2026-03-28 08:10:47 +00:00
Peter Steinberger
48b2eb2604 test: fix media and channel regression expectations 2026-03-28 08:10:47 +00:00
Peter Steinberger
7d000088a4 fix(ci): use process env for provider compat fallback 2026-03-28 08:10:47 +00:00
Peter Steinberger
f4fb45f1ee test: dedupe channel helper suites 2026-03-28 08:10:47 +00:00
Ayaan Zaidi
2181909f9a test: update image generation runtime seam 2026-03-28 13:39:55 +05:30
Ayaan Zaidi
0feeea0994 test: make media symlink fixture idempotent 2026-03-28 13:30:11 +05:30
Ayaan Zaidi
912bd1f5cc test: align media parse expectations 2026-03-28 13:29:14 +05:30
Peter Steinberger
bd4632b9c1 fix: mark buffered reply typing runs complete 2026-03-28 07:57:28 +00:00
Peter Steinberger
be38986141 fix: guard bundled channel runtime against TDZ imports 2026-03-28 07:57:28 +00:00
Ayaan Zaidi
04a40b2613 test: refresh base config schema snapshot 2026-03-28 13:25:37 +05:30
Ayaan Zaidi
8366c74ccd test: mock telegram conversation route seam 2026-03-28 13:17:57 +05:30
Peter Steinberger
d9e7178534 fix(ci): align embedded runner and bedrock typing drift 2026-03-28 07:33:19 +00:00
Peter Steinberger
71a3ad153a fix(ci): stabilize bundled capability contract loading 2026-03-28 07:33:19 +00:00
Peter Steinberger
f36354e401 test: dedupe pairing and channel contract suites 2026-03-28 07:31:40 +00:00
Peter Steinberger
e7c1fcba0c test: dedupe media utility suites 2026-03-28 07:31:40 +00:00
Peter Steinberger
155915e7dc test: dedupe routing and text suites 2026-03-28 07:31:40 +00:00
Peter Steinberger
30be04cd87 fix: include matrix runtime deps for bundled installs 2026-03-28 07:27:29 +00:00
Peter Steinberger
30bf4dd1ce test: isolate nextcloud talk from bundled channel imports 2026-03-28 07:18:07 +00:00
Peter Steinberger
2fd1a5274a fix: add getChat to telegram media test harness 2026-03-28 07:14:48 +00:00
Peter Steinberger
d69f20f451 fix: harden bundled channel runtime bootstrap 2026-03-28 07:10:05 +00:00
Peter Steinberger
f4cd06cb1a refactor: finish test cleanup off infra runtime 2026-03-28 06:59:32 +00:00
Peter Steinberger
5802d112da refactor: narrow telegram test mocks off infra runtime 2026-03-28 06:56:41 +00:00
Peter Steinberger
df4c9c5bd8 refactor: narrow test mocks off infra runtime 2026-03-28 06:54:03 +00:00
Peter Steinberger
61936938e9 refactor: move test harnesses off infra runtime 2026-03-28 06:52:06 +00:00
lixuankai
f0a57fad42 fix: isolate device chat defaults (#53752) (thanks @lixuankai)
* [feat]Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* feat(android): Multiple nodes session context isolated from each other

* fix(android): isolate device chat defaults

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-28 12:19:47 +05:30
Peter Steinberger
7db10d2103 refactor: route discord channel through outbound runtime 2026-03-28 06:45:20 +00:00
Peter Steinberger
c65ec46490 refactor: trim remaining infra runtime residue 2026-03-28 06:41:56 +00:00
Peter Steinberger
922c90e9fa refactor: add approval runtime sdk seam 2026-03-28 06:33:07 +00:00
Peter Steinberger
0d98ce1065 refactor: add diagnostic and error runtime sdk seams 2026-03-28 06:26:38 +00:00
Peter Steinberger
70c2458861 refactor: add host and collection runtime sdk seams 2026-03-28 06:19:16 +00:00
Peter Steinberger
df204b1d8f fix: restore pi embedded runner transport typing 2026-03-28 06:18:35 +00:00
Peter Steinberger
eef4a7ae64 refactor(commands): drop provider default facade shims 2026-03-28 06:11:13 +00:00
Peter Steinberger
d042192c7c refactor(plugins): move provider policy hooks into plugins 2026-03-28 06:11:13 +00:00
Gustavo Madeira Santana
d042543539 Tests: share agents bind command harness 2026-03-28 02:09:43 -04:00
Gustavo Madeira Santana
5292622fec Tests: share partial module mock helper 2026-03-28 02:09:43 -04:00
Peter Steinberger
1db1c75a98 refactor: trim state persistence runtime seams 2026-03-28 06:08:18 +00:00
Peter Steinberger
7206ddea6f test: dedupe plugin contract suites 2026-03-28 06:04:51 +00:00
Peter Steinberger
48b2291b1e test: dedupe plugin provider runtime suites 2026-03-28 06:04:51 +00:00
Peter Steinberger
89bb2cf03e test: dedupe plugin bundle discovery suites 2026-03-28 06:04:50 +00:00
Peter Steinberger
69b54cbb1f test: align pi tool schema fixture type 2026-03-28 05:59:27 +00:00
Peter Steinberger
c222a44e6f refactor: add retry runtime sdk seam 2026-03-28 05:59:07 +00:00
Peter Steinberger
d42c2f6a17 test(pi-tools): use typebox schema fixture 2026-03-28 05:53:59 +00:00
Peter Steinberger
2c636fa3b8 docs(acp): clarify current conversation bind support 2026-03-28 05:53:34 +00:00
Peter Steinberger
e246efb288 fix(runtime): align channel runtime api seams 2026-03-28 05:53:32 +00:00
Peter Steinberger
06fba21a9d test(acp): cover persisted generic conversation binds 2026-03-28 05:53:07 +00:00
Peter Steinberger
83135c31c9 refactor(acp): extract generic current conversation binding store 2026-03-28 05:53:07 +00:00
Gustavo Madeira Santana
bde5bae69f Tests: preserve heartbeat-wake exports in mocks 2026-03-28 01:52:55 -04:00
Gustavo Madeira Santana
1e4241c34a Matrix: fix directory auth and credentials fallback 2026-03-28 01:52:55 -04:00
Gustavo Madeira Santana
b253ca70ef Tests: stabilize Matrix-related shared suites 2026-03-28 01:52:55 -04:00
Ayaan Zaidi
29b6e27c9e fix(android): auto-send voice turns on silence 2026-03-28 11:17:13 +05:30
Peter Steinberger
e7a61d13f0 fix: route signal runtime barrel off denied subpath 2026-03-28 05:44:33 +00:00
Peter Steinberger
a126d23f0d refactor: add fetch runtime sdk seam 2026-03-28 05:44:33 +00:00
Peter Steinberger
5b544c295a style(tests): normalize plugin runtime test formatting 2026-03-28 05:42:46 +00:00
Peter Steinberger
b236f39104 refactor(agents): generalize tool schema compat cleanup 2026-03-28 05:42:46 +00:00
Peter Steinberger
c7883fe892 refactor(plugins): register provider model id hooks 2026-03-28 05:42:46 +00:00
Peter Steinberger
49f693d06a refactor: widen webhook request guard sdk seam 2026-03-28 05:28:10 +00:00
Peter Steinberger
d5841f6412 refactor: centralize plugin API assembly 2026-03-28 05:24:25 +00:00
Peter Steinberger
6a556c6851 test(gateway): add live docker ACP bind coverage 2026-03-28 05:23:55 +00:00
Peter Steinberger
19e8e7190b fix(acp): avoid no-op gateway self-call after spawn 2026-03-28 05:23:55 +00:00
Peter Steinberger
b12f3ce6e5 fix(gateway): support synthetic chat origins 2026-03-28 05:23:55 +00:00
Tak Hoffman
26789db868 Fix TTS contract registry test context 2026-03-28 00:23:26 -05:00
Peter Steinberger
23f0486810 fix: stabilize plugin startup boundaries 2026-03-28 05:22:26 +00:00
Peter Steinberger
838013c87a refactor: expose webhook request guard sdk seam 2026-03-28 05:17:19 +00:00
Ayaan Zaidi
a7b8034a2b fix(android): use native tts in voice tab 2026-03-28 10:47:08 +05:30
Ayaan Zaidi
79fb980767 test(config): refresh base schema snapshot 2026-03-28 10:47:08 +05:30
Gustavo Madeira Santana
21c00165ef test: fix gateway handler and typing lease helper types 2026-03-28 01:11:24 -04:00
Gustavo Madeira Santana
e2a2492248 Secrets: fix Matrix default-account password activity 2026-03-28 01:08:33 -04:00
Peter Steinberger
38c65b4096 refactor: route slack prepare events through channel runtime 2026-03-28 05:06:20 +00:00
Peter Steinberger
f01f2ddc6d test(matrix): restore sdk mock ordering 2026-03-28 05:04:07 +00:00
Peter Steinberger
ccf54f263a refactor: route slack interactions through channel runtime 2026-03-28 05:03:22 +00:00
Ayaan Zaidi
16f8616d9d test(plugins): simplify typing pulse mock helper 2026-03-28 10:33:05 +05:30
Ayaan Zaidi
3a341355bf test(gateway): fill channels status handler options 2026-03-28 10:33:05 +05:30
Peter Steinberger
ab2bd34b66 refactor(xai): split provider compat facades
Co-authored-by: Harold Hunt <harold@pwrdrvr.com>
2026-03-28 05:02:41 +00:00
Peter Steinberger
c4e6fdf94d refactor(xai): move bundled xai runtime into plugin
Co-authored-by: Harold Hunt <harold@pwrdrvr.com>
2026-03-28 05:02:41 +00:00
Tak Hoffman
85064256a2 Refresh bundled plugin metadata snapshot 2026-03-28 00:00:14 -05:00
Peter Steinberger
02b8d47c6c test: align slots helper types 2026-03-28 04:58:53 +00:00
Peter Steinberger
6d3a6bda3d test: tighten typing lease mock helpers 2026-03-28 04:58:53 +00:00
Peter Steinberger
be31e7aa4c fix: unblock telegram typing and topic runtime builds 2026-03-28 04:58:34 +00:00
Peter Steinberger
ba02905c4f refactor: split mcp channel bridge internals 2026-03-28 04:58:34 +00:00
Ayaan Zaidi
fe679f0a90 fix(telegram): tighten reaction typings 2026-03-28 10:28:24 +05:30
Tak Hoffman
a790f63056 Fix typing lease background failure tests 2026-03-27 23:57:27 -05:00
Peter Steinberger
7d7883aa38 refactor: use temp-path sdk in discord voice manager 2026-03-28 04:56:53 +00:00
Tak Hoffman
0bcf076901 fix(regression): auto-enable channel status state 2026-03-27 23:56:29 -05:00
Peter Steinberger
dc87ffa46d fix(ci): guard telegram native command auth typing 2026-03-28 04:55:26 +00:00
Peter Steinberger
090a767754 fix: tighten telegram runtime type guards 2026-03-28 04:53:26 +00:00
Brad Groux
6b0e74000d fix(msteams): add blockStreaming config and progressive delivery (#56134)
- Add blockStreaming and blockStreamingCoalesceDefaults to MSTeams channel plugin (was the only channel missing it)
- Wire disableBlockStreaming flag in reply dispatcher from config
- Flush pending messages immediately during generation when blockStreaming is enabled
- Add comprehensive tests for schema validation and progressive flush behavior

Refs #56041
2026-03-27 23:53:24 -05:00
Peter Steinberger
4900890626 test: align macOS config audit expectations 2026-03-28 04:53:02 +00:00
Peter Steinberger
a70d9beb3a build: update macOS package dependencies 2026-03-28 04:53:02 +00:00
Tak Hoffman
3e8bad0d31 Refresh bundled plugin metadata snapshot 2026-03-27 23:52:32 -05:00
Tak Hoffman
b8012221d2 fix(regression): restore slots test helper typing 2026-03-27 23:52:08 -05:00
Tak Hoffman
ff348d2063 fix(regression): auto-enable gateway send selection 2026-03-27 23:51:28 -05:00
Peter Steinberger
222ba9f174 fix(ci): tighten telegram and typing test types 2026-03-28 04:49:21 +00:00
Gustavo Madeira Santana
470d6aee0f Gateway: keep auto-enabled plugin config through startup 2026-03-28 00:49:00 -04:00
Tak Hoffman
5167841ff8 fix(regression): auto-enable channels resolve selection 2026-03-27 23:48:54 -05:00
Tak Hoffman
897a6a6c5b fix(regression): auto-enable message channel selection 2026-03-27 23:47:56 -05:00
Tak Hoffman
384bdde514 fix(regression): persist auto-enabled directory config 2026-03-27 23:47:54 -05:00
Peter Steinberger
687d23ae8d test: restore extension boundary guardrails 2026-03-28 04:47:31 +00:00
Peter Steinberger
d29d56c090 build: update Peekaboo for macOS SDK compatibility 2026-03-28 04:47:31 +00:00
Tak Hoffman
46ab177743 fix(regression): persist auto-enabled channel auth config 2026-03-27 23:45:57 -05:00
Tak Hoffman
8539886cd8 fix(regression): auto-enable directory channel selection 2026-03-27 23:45:29 -05:00
Peter Steinberger
811685b95f test: dedupe plugin bundle boundary suites 2026-03-28 04:44:58 +00:00
Peter Steinberger
fc84dd398b test: dedupe plugin runtime registry suites 2026-03-28 04:43:29 +00:00
Peter Steinberger
25fea00bc7 test: dedupe plugin utility config suites 2026-03-28 04:43:29 +00:00
Tak Hoffman
c9d5d12183 fix(regression): auto-enable channel auth selection 2026-03-27 23:42:36 -05:00
Peter Steinberger
324c621ebe fix(ci): align telegram runtime and test drift 2026-03-28 04:41:23 +00:00
Tak Hoffman
7779205aa1 Keep matrix SDK external in bundle checks 2026-03-27 23:41:00 -05:00
Tak Hoffman
363038828f fix(regression): auto-enable gateway bootstrap snapshots 2026-03-27 23:40:51 -05:00
Peter Steinberger
0c729b6d30 test: dedupe plugin runtime utility suites 2026-03-28 04:40:08 +00:00
Peter Steinberger
12318d25ae test: dedupe plugin provider runtime status suites 2026-03-28 04:40:08 +00:00
Tak Hoffman
6fc949862a fix(regression): repair channel setup discovery test 2026-03-27 23:38:55 -05:00
Tak Hoffman
37ab1513e0 fix(regression): auto-enable channel setup discovery 2026-03-27 23:38:55 -05:00
Tak Hoffman
84af16e9c7 fix(regression): handle telegram command error envelopes 2026-03-27 23:36:37 -05:00
Gustavo Madeira Santana
b5958ce5fd Changelog: note plugin runtime fixes 2026-03-28 00:36:13 -04:00
Tak Hoffman
1b5043f47b fix(regression): auto-enable gateway plugin loads 2026-03-27 23:35:22 -05:00
Ayaan Zaidi
6949e17429 refactor(telegram): simplify transport typing 2026-03-28 10:05:11 +05:30
Gustavo Madeira Santana
59535e3414 Matrix: align default account secret handling 2026-03-28 00:34:48 -04:00
Tak Hoffman
411494faa8 fix(regression): guard malformed telegram reaction payloads 2026-03-27 23:34:09 -05:00
Tak Hoffman
1a7dc22995 fix(regression): remove duplicate media runtime config 2026-03-27 23:30:08 -05:00
Peter Steinberger
afdcf16528 docs: note grouped git sync workflow 2026-03-28 04:29:55 +00:00
Tak Hoffman
f672782f38 Stabilize slack interaction event mocks 2026-03-27 23:29:42 -05:00
Tak Hoffman
3ccc58ae29 Restore channel test module rebinding 2026-03-27 23:29:42 -05:00
Peter Steinberger
22c9be197e docs: tighten Mistral changelog wording 2026-03-28 04:29:22 +00:00
Peter Steinberger
244b70051e test: fix docker mcp stdio notification hook 2026-03-28 04:29:13 +00:00
Peter Steinberger
47b3bf8c89 test: drop unused capability test helper 2026-03-28 04:28:54 +00:00
Peter Steinberger
9155f3914a test: dedupe plugin provider helper suites 2026-03-28 04:28:54 +00:00
Peter Steinberger
7e921050e3 test: dedupe plugin lifecycle runtime suites 2026-03-28 04:28:54 +00:00
Peter Steinberger
04792e6c44 test: dedupe plugin bundle and discovery suites 2026-03-28 04:28:54 +00:00
Ayaan Zaidi
8465ddc1cc refactor(telegram): tighten helper field readers 2026-03-28 09:57:46 +05:30
Ayaan Zaidi
f9aa226d93 refactor(telegram): simplify action button parsing 2026-03-28 09:57:46 +05:30
Ayaan Zaidi
ed441b180b refactor(telegram): simplify message helper parsing 2026-03-28 09:57:46 +05:30
Tak Hoffman
d69664e107 fix(regression): preserve googlechat pairing account context 2026-03-27 23:25:30 -05:00
Gustavo Madeira Santana
286d6b388f Tests: remove stale runtime state setup 2026-03-28 00:25:14 -04:00
Gustavo Madeira Santana
cdf19111e5 Plugins: narrow loader testing helper surface 2026-03-28 00:25:14 -04:00
Tak Hoffman
742e0c8597 fix(regression): track outbound bootstrap by channel surface 2026-03-27 23:24:51 -05:00
Peter Steinberger
eca6b8f4e8 refactor: use temp-path sdk in discord voice message 2026-03-28 04:23:39 +00:00
Tak Hoffman
d6fafb8af9 Add collect-all test failure planning 2026-03-27 23:23:31 -05:00
Ayaan Zaidi
39829b5dc6 refactor(telegram): unify inline button capability parsing 2026-03-28 09:52:21 +05:30
Ayaan Zaidi
4a014083ff refactor(telegram): tighten api result typings 2026-03-28 09:52:21 +05:30
Ayaan Zaidi
9905f39e9d refactor(telegram): unify chat metadata parsing 2026-03-28 09:52:21 +05:30
Tak Hoffman
12488f45c2 fix(regression): preserve announce thread ids 2026-03-27 23:22:17 -05:00
Tak Hoffman
c0d4c07b88 fix(regression): scope plugin registry reuse by gateway methods 2026-03-27 23:22:10 -05:00
kakahu
158e7c517e fix(matrix): resolve env SecretRef fallback in clean() for channel startup (#54980)
Merged via squash.

Prepared head SHA: b71a86e68e
Co-authored-by: kakahu2015 <17962485+kakahu2015@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-28 00:21:57 -04:00
Peter Steinberger
9fa1674a56 refactor: use temp-path sdk in discord send 2026-03-28 04:20:20 +00:00
Gustavo Madeira Santana
392724ae57 Plugins: reuse shared bootstrap registry resolution 2026-03-28 00:19:33 -04:00
Gustavo Madeira Santana
ee7f5825c8 Plugins: share runtime registry resolution 2026-03-28 00:19:33 -04:00
Tak Hoffman
f811ce5052 fix(regression): preserve bluebubbles pairing account context 2026-03-27 23:18:10 -05:00
Tak Hoffman
c7e05f1f87 test(regression): cover mistral availability compat 2026-03-27 23:17:35 -05:00
Peter Steinberger
ee73342445 refactor: use channel runtime for imessage readiness 2026-03-28 04:16:35 +00:00
Tak Hoffman
a4c64d82f8 fix(regression): preserve telegram pairing account context 2026-03-27 23:15:23 -05:00
Peter Steinberger
bd28e6d444 refactor: move transport readiness onto channel runtime 2026-03-28 04:13:40 +00:00
Peter Steinberger
df4fd12225 test(matrix): mock chunkTextForOutbound in monitor registration test 2026-03-28 04:12:26 +00:00
Tak Hoffman
2aa09230c2 fix(regression): preserve feishu pairing account context 2026-03-27 23:12:18 -05:00
Ayaan Zaidi
efef319496 refactor(telegram): tighten chat action typings 2026-03-28 09:41:18 +05:30
Ayaan Zaidi
4018d04d56 refactor(telegram): simplify runtime handler typing 2026-03-28 09:41:18 +05:30
Ayaan Zaidi
3a2bf0aa1f refactor(telegram): share chat lookup types 2026-03-28 09:41:18 +05:30
Peter Steinberger
048a4e4f9e docs: clarify mcp server and client modes 2026-03-28 04:10:20 +00:00
Peter Steinberger
ec5877346c fix: harden mcp channel bridge smoke 2026-03-28 04:10:19 +00:00
Gustavo Madeira Santana
9b405f88d4 Plugins: reuse compatible runtime web search registries 2026-03-28 00:09:37 -04:00
Gustavo Madeira Santana
a00127bf5b Plugins: reuse compatible registries for runtime providers 2026-03-28 00:09:37 -04:00
Gustavo Madeira Santana
fd0aac297c Plugins: add runtime registry compatibility helper 2026-03-28 00:09:37 -04:00
Peter Steinberger
4beb231fd8 refactor: move heartbeat helpers onto channel runtime 2026-03-28 04:09:25 +00:00
Tak Hoffman
b9415ca24b fix(regression): preserve line pairing account context 2026-03-27 23:09:05 -05:00
Tak Hoffman
d50526dddc fix(regression): use active channel registry for generic bindings 2026-03-27 23:08:56 -05:00
Peter Steinberger
5e93419c31 fix: move Mistral compat into provider plugin 2026-03-28 04:08:37 +00:00
Tak Hoffman
fd48e4090a fix(regression): reject disabled channel auth stubs 2026-03-27 23:06:06 -05:00
Peter Steinberger
4e50548e46 fix: restore skill sourceInfo provenance handling 2026-03-28 04:05:18 +00:00
Tak Hoffman
102e313d55 fix(regression): refresh provider hook cache after config changes 2026-03-27 23:04:24 -05:00
Tak Hoffman
1e2e6fb613 fix(regression): allow auth-capable channel auto-pick without raw config 2026-03-27 23:03:52 -05:00
Peter Steinberger
578d02f40a test: dedupe plugin lifecycle registry suites 2026-03-28 04:02:35 +00:00
Peter Steinberger
e74f206a68 test: dedupe plugin provider runtime suites 2026-03-28 04:02:34 +00:00
Peter Steinberger
708ff9145e test: dedupe plugin utility config suites 2026-03-28 04:02:13 +00:00
Tak Hoffman
c5b1582d48 fix(regression): auto-enable web search provider loads 2026-03-27 23:00:49 -05:00
Nyanako
f652d9fd81 fix: preserve indentation when stripping reply directives (#55960) (thanks @Nanako0129)
* fix: preserve indentation when stripping reply directives

* fix: preserve word boundaries when stripping reply directives

* fix: drop separator space after leading reply directives

* fix: preserve indentation when stripping reply directives (#55960) (thanks @Nanako0129)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-28 09:28:45 +05:30
Peter Steinberger
0d0d46f5e9 refactor: route line media temp paths through temp-path sdk 2026-03-28 03:57:46 +00:00
Peter Steinberger
5acc4b5dc5 refactor: route acpx test temp paths through temp-path sdk 2026-03-28 03:56:29 +00:00
Tak Hoffman
ec122796f8 fix(regression): avoid loading memory runtime during shutdown 2026-03-27 22:55:40 -05:00
Peter Steinberger
c5c9640374 fix: harden config write auditing 2026-03-28 03:54:54 +00:00
Peter Steinberger
5853b1aab8 fix: replay skill source drift 2026-03-28 03:53:59 +00:00
Peter Steinberger
9058662d6f refactor: route signal event handler through channel runtime 2026-03-28 03:53:59 +00:00
Peter Steinberger
49968982a5 fix(plugin-sdk): avoid testing export drift 2026-03-28 03:53:38 +00:00
Peter Steinberger
dee2bde2f5 test(acp): cover generic conversation binds 2026-03-28 03:53:38 +00:00
Peter Steinberger
ec9f96cb2a refactor(plugin-sdk): align binding contract imports 2026-03-28 03:53:38 +00:00
Peter Steinberger
d0d4b73d25 refactor(acp): centralize conversation binding context 2026-03-28 03:53:38 +00:00
Tak Hoffman
09e35e69b2 fix(regression): auto-enable provider runtime loads 2026-03-27 22:53:32 -05:00
Peter Steinberger
cc9b2df97c test: stabilize telegram stalled-runner restart assertion 2026-03-28 03:51:16 +00:00
Ayaan Zaidi
921bb89b1a fix: add changelog for CJK MMR tokenize fix (#29396) (thanks @buyitsydney) 2026-03-28 09:19:52 +05:30
buyitsydney
4b69c6d3f1 fix(memory): add CJK/Kana/Hangul support to MMR tokenize() for diversity detection
The tokenize() function only matched [a-z0-9_]+ patterns, returning an
empty set for CJK-only text. This made Jaccard similarity always 0 (or
always 1 for two empty sets) for CJK content, effectively disabling MMR
diversity detection.

Add support for:
- CJK Unified Ideographs (U+4E00–U+9FFF, U+3400–U+4DBF)
- Hiragana (U+3040–U+309F) and Katakana (U+30A0–U+30FF)
- Hangul Syllables (U+AC00–U+D7AF) and Jamo (U+1100–U+11FF)

Characters are extracted as unigrams, and bigrams are generated only
from characters that are adjacent in the original text (no spurious
bigrams across ASCII boundaries).

Fixes #28000
2026-03-28 09:19:52 +05:30
chen-zhang-cs-code
92b8839488 fix: normalize unsupported Brave country filters (#55695) (thanks @chen-zhang-cs-code)
* fix(brave): normalize unsupported country filters

* fix: normalize unsupported Brave country filters (#55695) (thanks @chen-zhang-cs-code)

* fix: annotate Brave country enum source (#55695) (thanks @chen-zhang-cs-code)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-28 09:19:27 +05:30
Peter Steinberger
faae213ab7 refactor: route whatsapp monitor through channel runtime 2026-03-28 03:48:57 +00:00
Peter Steinberger
8147f5075b refactor: inline canonical skill source reads 2026-03-28 03:48:17 +00:00
Tak Hoffman
f4f492a410 fix(regression): scope channel setup reloads by channel registry 2026-03-27 22:46:47 -05:00
Peter Steinberger
ec6fba7d01 refactor: drop legacy skill source fallback 2026-03-28 03:45:56 +00:00
Peter Steinberger
24bb64b1c4 test: centralize canonical skill fixtures 2026-03-28 03:45:56 +00:00
Tak Hoffman
63e35b2d9d fix(regression): auto-enable memory runtime loads 2026-03-27 22:44:12 -05:00
Tak Hoffman
2ba5e7ebf9 fix(regression): align plugin inspect policy with auto-enabled config 2026-03-27 22:42:59 -05:00
Peter Steinberger
550c51bb6e refactor: route telegram bot deps through channel runtime 2026-03-28 03:42:37 +00:00
Tak Hoffman
2030c814ce fix(regression): auto-enable channel setup registry loads 2026-03-27 22:41:50 -05:00
Peter Steinberger
66c4c3bec8 test: align matrix runtime api allowlist 2026-03-28 03:40:51 +00:00
Tak Hoffman
36ac9224cc fix(regression): reload stale auto-enabled plugin tool registries 2026-03-27 22:40:24 -05:00
Tak Hoffman
e20823c741 fix(regression): auto-enable plugin status loads 2026-03-27 22:39:04 -05:00
Peter Steinberger
f3c8c27b3a fix: replay skill source fixture drift 2026-03-28 03:38:11 +00:00
Peter Steinberger
d83e3afc56 refactor: move slack system events onto channel runtime 2026-03-28 03:38:11 +00:00
Tak Hoffman
7918524229 fix(regression): reload stale preseeded cli channel registries 2026-03-27 22:37:58 -05:00
Tak Hoffman
cfd1e94e61 fix(regression): auto-enable plugin tool loads 2026-03-27 22:36:41 -05:00
Tak Hoffman
a6e597eda3 fix(regression): preserve plugin identity in hook test helpers 2026-03-27 22:34:09 -05:00
Tak Hoffman
8075641ce4 fix(regression): auto-enable plugin cli loads 2026-03-27 22:33:26 -05:00
Tak Hoffman
3f0b3a553a fix(regression): require channel scope in preseeded cli registry 2026-03-27 22:32:12 -05:00
Peter Steinberger
db2046f92f test: harden extension integration fixtures 2026-03-28 03:31:42 +00:00
Peter Steinberger
32fd469b2c test: align skill fixture source info 2026-03-28 03:31:42 +00:00
Tak Hoffman
ce7b3c94e0 fix(regression): merge aliased auth order provider keys 2026-03-27 22:31:07 -05:00
Peter Steinberger
b4c38c78f3 test: dedupe plugin provider runtime suites 2026-03-28 03:30:25 +00:00
Peter Steinberger
de173f0e3e test: dedupe plugin utility install suites 2026-03-28 03:30:25 +00:00
Peter Steinberger
1256943a46 test: dedupe plugin hook runner suites 2026-03-28 03:30:25 +00:00
Tak Hoffman
0946fdf625 fix(regression): widen preseeded cli plugin registry loads 2026-03-27 22:29:16 -05:00
Tak Hoffman
967702d928 test(regression): cover irc plugin-sdk facade exports 2026-03-27 22:28:04 -05:00
YTjungle
cb802afcbb fix(control-ui): size grouped chat bubbles by content 2026-03-27 22:27:47 -05:00
Peter Steinberger
2b450ab629 refactor: move discord system events onto channel runtime 2026-03-28 03:27:12 +00:00
Tak Hoffman
52def05ecd fix(regression): canonicalize auth order provider keys 2026-03-27 22:27:05 -05:00
Tak Hoffman
7bccf68794 fix(regression): preserve voice call timeout markers before hangup 2026-03-27 22:25:32 -05:00
Tak Hoffman
83adbc840c fix(regression): restore irc cold-runtime chunking 2026-03-27 22:24:27 -05:00
Peter Steinberger
71795c5323 refactor: move discord error formatting onto ssrf runtime 2026-03-28 03:22:16 +00:00
Tak Hoffman
3b8564a7c6 test(regression): cover setup and policy plugin-sdk facades 2026-03-27 22:20:40 -05:00
Peter Steinberger
c04ceb5cc2 refactor: route discord preflight activity through channel runtime 2026-03-28 03:19:50 +00:00
Peter Steinberger
8c277121d9 refactor: dedupe channel secret collectors 2026-03-28 03:18:54 +00:00
Peter Steinberger
07d386c2bb fix: dedupe voice call lifecycle cleanup 2026-03-28 03:18:54 +00:00
Peter Steinberger
0825ff9619 refactor: move discord duration formatting onto runtime env 2026-03-28 03:17:40 +00:00
Tak Hoffman
c1abf7c8c0 test(regression): cover bluebubbles plugin-sdk facade exports 2026-03-27 22:15:22 -05:00
Peter Steinberger
8ed25f95dd refactor: route discord activity through channel runtime 2026-03-28 03:15:03 +00:00
Tak Hoffman
08cd52b7c6 test(regression): cover cold-runtime plugin-sdk chunking exports 2026-03-27 22:12:39 -05:00
Peter Steinberger
277af32485 refactor: remove plugin sdk extension facade smells 2026-03-28 03:12:07 +00:00
Tak Hoffman
8c60e4e9f9 fix(regression): normalize image tool provider config aliases 2026-03-27 22:09:52 -05:00
Peter Steinberger
21136238ce test(discord): add acp bind flow integration coverage 2026-03-28 03:09:38 +00:00
Peter Steinberger
e11a74843e test: dedupe plugin hook merger suites 2026-03-28 03:08:10 +00:00
Peter Steinberger
218a711d5e test: dedupe plugin command and runtime helpers 2026-03-28 03:06:27 +00:00
Peter Steinberger
95acd74d7c test: dedupe plugin bundle and discovery helpers 2026-03-28 03:06:27 +00:00
Peter Steinberger
7a6f32a730 fix: replay skill source fixture drift 2026-03-28 03:06:06 +00:00
Peter Steinberger
12b7327e16 refactor: move secure random helpers onto core sdk 2026-03-28 03:06:06 +00:00
Neerav Makwana
b98a6c223d gateway: reuse session workspace for HTTP tool loading (#56101)
Merged via squash.

Prepared head SHA: f3006d77f7
Co-authored-by: neeravmakwana <261249544+neeravmakwana@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 23:01:43 -04:00
Tak Hoffman
ae2b1aef10 fix(regression): normalize model picker provider endpoint aliases 2026-03-27 22:01:39 -05:00
Peter Steinberger
969294f8c5 test: dedupe plugin install and packaging suites 2026-03-28 03:00:51 +00:00
Peter Steinberger
39f6fe9ab1 test: dedupe plugin runtime and provider suites 2026-03-28 03:00:51 +00:00
Peter Steinberger
b34b03dd9e refactor: move channel dedupe helpers onto core sdk 2026-03-28 02:58:45 +00:00
Peter Steinberger
024f2cf6e6 style: apply oxfmt drift 2026-03-28 02:55:07 +00:00
Peter Steinberger
67d0ecf5ec fix(ci): align skill fixture source info 2026-03-28 02:55:07 +00:00
Peter Steinberger
68416fdf83 refactor(acp): generalize message-channel binds 2026-03-28 02:53:54 +00:00
Peter Steinberger
491969efb0 refactor: route channel activity through channel runtime 2026-03-28 02:53:03 +00:00
Tak Hoffman
684a1565a9 fix(regression): align feishu send helper runtime usage 2026-03-27 21:52:06 -05:00
Peter Steinberger
c69a70714c test: harden contract registry fixtures 2026-03-28 02:49:49 +00:00
Peter Steinberger
c9c1e456d1 fix: replay skill source fixture drift 2026-03-28 02:48:35 +00:00
Peter Steinberger
00dcfa1b3d refactor: move channel backoff helpers onto runtime-env 2026-03-28 02:48:35 +00:00
Tak Hoffman
01e3dd3508 fix(regression): normalize provider aliases in context window guard 2026-03-27 21:47:59 -05:00
Tak Hoffman
4ec51f2d5f fix(regression): align msteams send helper runtime usage 2026-03-27 21:46:42 -05:00
Tak Hoffman
912a26e759 fix(regression): align mattermost send helper runtime usage 2026-03-27 21:45:10 -05:00
Peter Steinberger
b171e42117 refactor: move telegram timing helpers onto runtime-env 2026-03-28 02:43:29 +00:00
Peter Steinberger
71f37a59ca feat: add openclaw channel mcp bridge 2026-03-28 02:41:57 +00:00
Tak Hoffman
a65d603b31 fix(regression): align irc send helper runtime usage 2026-03-27 21:40:58 -05:00
Peter Steinberger
ea92003384 test: replay skill source fixture drift 2026-03-28 02:40:05 +00:00
Peter Steinberger
6a2c5b2b54 refactor: move telegram error formatting onto ssrf runtime 2026-03-28 02:38:02 +00:00
Tak Hoffman
33e64cfb64 fix(regression): align nextcloud-talk send helper runtime usage 2026-03-27 21:37:50 -05:00
Sid Uppal
295d1de8d9 fix(msteams): reset stream state after tool calls to prevent message loss (#56071)
* fix(msteams): reset stream state after preparePayload suppresses delivery

When an agent uses tools mid-response (text → tool calls → more text),
the stream controller's preparePayload would suppress fallback delivery
for ALL text segments because streamReceivedTokens stayed true. This
caused the second text segment to be silently lost or duplicated.

Fix: after preparePayload suppresses delivery for a streamed segment,
finalize the stream and reset streamReceivedTokens so subsequent
segments use fallback delivery.

Fixes openclaw/openclaw#56040

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

* fix(msteams): guard preparePayload against finalized stream re-suppression

When onPartialReply fires after the stream is finalized (post-tool
partial tokens), streamReceivedTokens gets set back to true but the
stream can't deliver. Add stream.isFinalized check so a finalized
stream never suppresses fallback delivery.

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

* fix(msteams): await pending finalize in controller to prevent race

Store the fire-and-forget finalize promise from preparePayload and
await it in the controller's finalize() method. This ensures
markDispatchIdle waits for the in-flight stream finalization to
complete before context cleanup.

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

* test(msteams): add edge case tests for multi-round and media payloads

Add tests for 3+ tool call rounds (text → tool → text → tool → text)
and media+text payloads after stream finalization, covering the full
contract of preparePayload across all input types and cycle counts.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:36:37 -05:00
Peter Steinberger
4752aca926 test: dedupe plugin runtime registry suites 2026-03-28 02:34:39 +00:00
Peter Steinberger
a9be5421d0 test: dedupe plugin provider runtime suites 2026-03-28 02:34:39 +00:00
Peter Steinberger
0454612083 test: dedupe plugin bundle and discovery suites 2026-03-28 02:34:39 +00:00
Peter Steinberger
c18d315858 fix: replay skill source fixture drift 2026-03-28 02:34:11 +00:00
Peter Steinberger
6b4d097b25 refactor: route telegram env helpers through runtime-env 2026-03-28 02:34:11 +00:00
Tak Hoffman
d027b442af fix(regression): restore zalouser cold-runtime chunking 2026-03-27 21:33:14 -05:00
Peter Steinberger
05719648a1 test(line): isolate status probe fallback import state 2026-03-28 02:31:39 +00:00
Tak Hoffman
a3961d098a fix(regression): preserve mattermost reaction channel routing 2026-03-27 21:30:24 -05:00
Tak Hoffman
42ecfffbff fix(regression): restore signal cold-runtime chunking 2026-03-27 21:28:18 -05:00
Tak Hoffman
bd7375f84a fix: normalize image provider alias selection 2026-03-27 21:28:15 -05:00
Peter Steinberger
1bf8d69d95 refactor(msteams): share conversation store helpers 2026-03-28 02:26:48 +00:00
Peter Steinberger
4031bb1914 refactor: trim secret and ssrf helper runtime seams 2026-03-28 02:25:28 +00:00
Tak Hoffman
18fe752c48 fix(regression): restore googlechat cold-runtime chunking 2026-03-27 21:25:21 -05:00
Peter Steinberger
70a0ce2179 test: align skill fixture source info 2026-03-28 02:24:34 +00:00
Tak Hoffman
d519beb925 fix: normalize model scan provider filters 2026-03-27 21:23:51 -05:00
Tak Hoffman
3143cf86e8 fix(regression): restore whatsapp cold-runtime chunking 2026-03-27 21:23:18 -05:00
Tak Hoffman
e57342c7f2 fix(regression): restore msteams cold-runtime chunking 2026-03-27 21:21:40 -05:00
Peter Steinberger
3b9eb2cd1b refactor: trim bluebubbles runtime seams 2026-03-28 02:21:34 +00:00
Tak Hoffman
e5c4e89dc6 fix: normalize explicit context provider aliases 2026-03-27 21:21:32 -05:00
Tak Hoffman
fc542671eb fix: normalize history session provider lookup 2026-03-27 21:19:09 -05:00
Tak Hoffman
c0c32445ab fix(regression): restore feishu cold-runtime chunking 2026-03-27 21:17:17 -05:00
Tak Hoffman
5426bdf391 fix: normalize model catalog provider lookup 2026-03-27 21:16:44 -05:00
Peter Steinberger
46a44c5044 refactor: trim tlon runtime helper seams 2026-03-28 02:15:31 +00:00
Tak Hoffman
23d5bad3ae fix(regression): restore matrix cold-runtime chunking 2026-03-27 21:14:38 -05:00
Tak Hoffman
e83b1d7c43 fix: normalize live model provider aliases 2026-03-27 21:12:45 -05:00
Tak Hoffman
196d347153 fix(regression): restore mattermost cold-runtime chunking 2026-03-27 21:12:13 -05:00
Peter Steinberger
185668f5c5 refactor: trim extension helper runtime seams 2026-03-28 02:12:05 +00:00
Tak Hoffman
4cc8f8a1c6 fix: normalize models list provider filters 2026-03-27 21:10:35 -05:00
Tak Hoffman
e4538a2a70 fix(regression): classify toolcall content as tool output 2026-03-27 21:08:58 -05:00
Peter Steinberger
ce2444403e refactor: trim provider oauth runtime seams 2026-03-28 02:08:29 +00:00
Tak Hoffman
2638b566f1 fix(regression): canonicalize chat final session routing 2026-03-27 21:06:45 -05:00
Tak Hoffman
2877a7d8b2 fix: normalize status summary provider config lookup 2026-03-27 21:06:32 -05:00
Peter Steinberger
c1fb18189b test: dedupe plugin hook runner suites 2026-03-28 02:05:01 +00:00
Peter Steinberger
7d79134cee test: dedupe plugin runtime utility suites 2026-03-28 02:05:01 +00:00
Peter Steinberger
2926c25e10 fix: prefer freshest Teams DM reference (#54702) (thanks @gumclaw) 2026-03-28 02:04:51 +00:00
gumclaw
a717819f78 msteams: align memory store user resolution 2026-03-28 02:04:51 +00:00
gumclaw
28eb5ece14 msteams: prefer freshest personal conversation reference 2026-03-28 02:04:51 +00:00
Peter Steinberger
2c15960ac2 fix: replay skill fixture source drift 2026-03-28 02:04:31 +00:00
Peter Steinberger
e8866fc738 refactor: narrow provider runtime auth seams 2026-03-28 02:04:31 +00:00
Tak Hoffman
a0f48f099e fix(regression): canonicalize chat inject session routing 2026-03-27 21:04:16 -05:00
Tak Hoffman
7ccf4552ac fix: normalize provider catalog config lookup 2026-03-27 21:03:53 -05:00
Tak Hoffman
59a0411a78 fix(regression): canonicalize exec session routing 2026-03-27 21:02:03 -05:00
Tak Hoffman
fe295b15a5 fix: normalize provider catalog template lookup 2026-03-27 21:01:18 -05:00
Peter Steinberger
269f461b2e test: isolate zai probe target env in alias coverage 2026-03-28 02:00:53 +00:00
Tak Hoffman
8aace2b448 fix(regression): hydrate node tool event metadata 2026-03-27 21:00:46 -05:00
Peter Steinberger
72ba2b3653 chore: bump version metadata to 2026.3.27 2026-03-28 02:00:22 +00:00
Tak Hoffman
392c15aa73 fix: dedupe canonical providers in models status 2026-03-27 20:59:04 -05:00
Tak Hoffman
ee72081373 fix(regression): restore googlechat cold-runtime media send 2026-03-27 20:58:47 -05:00
Tak Hoffman
50c87c4682 fix: normalize catalog provider ids for probe model selection 2026-03-27 20:56:27 -05:00
Tak Hoffman
e890cde041 fix(regression): hydrate run-scoped tool event metadata 2026-03-27 20:56:04 -05:00
Peter Steinberger
c42ec81e37 feat(acp): add conversation binds for message channels 2026-03-28 01:54:25 +00:00
Tak Hoffman
067f8db4c9 fix(regression): preserve lifecycle session ownership metadata 2026-03-27 20:53:46 -05:00
Peter Steinberger
923b316ddc fix: harden parallels smoke verification 2026-03-28 01:51:18 +00:00
Tak Hoffman
a724246547 fix(regression): restore imessage cold-runtime chunking 2026-03-27 20:50:03 -05:00
Tak Hoffman
dd78b16cdc fix: normalize auth health provider aliases 2026-03-27 20:45:10 -05:00
Tak Hoffman
a265c59418 fix(regression): preserve transcript session ownership metadata 2026-03-27 20:43:56 -05:00
Tak Hoffman
9a57bdfdf1 fix(regression): preserve session tool event metadata 2026-03-27 20:42:38 -05:00
Gustavo Madeira Santana
86d8b06da9 Matrix: preserve strict DM SAS fallback 2026-03-27 21:42:12 -04:00
Tak Hoffman
724a9cfdba fix: preserve fallback provider capabilities under partial overrides 2026-03-27 20:40:53 -05:00
Peter Steinberger
43ba3ab6b5 refactor: scope provider runtime to enabled provider plugins 2026-03-28 01:40:30 +00:00
Peter Steinberger
1425259274 refactor: split bedrock provider stream helpers 2026-03-28 01:40:30 +00:00
Tak Hoffman
02bce20dd0 fix: prefer canonical cli backend config keys 2026-03-27 20:38:51 -05:00
Peter Steinberger
c364fc8428 test: dedupe plugin manifest and wizard suites 2026-03-28 01:38:12 +00:00
Peter Steinberger
fad42b19ee test: dedupe plugin core utility suites 2026-03-28 01:38:12 +00:00
Peter Steinberger
2accc0391a test: dedupe security utility suites 2026-03-28 01:38:12 +00:00
Tak Hoffman
87875430a8 fix(regression): preserve chat lifecycle subagent metadata 2026-03-27 20:37:22 -05:00
Tak Hoffman
7a1f64e86b fix: prefer profile auth in provider summaries 2026-03-27 20:36:06 -05:00
Tak Hoffman
9e16374898 fix(regression): restore signal cold-runtime status probing 2026-03-27 20:34:58 -05:00
Tak Hoffman
b9b84f2572 fix(regression): restore line cold-runtime status probing 2026-03-27 20:33:09 -05:00
Tak Hoffman
d11dc8feba fix: summarize plugin tool descriptions in catalog 2026-03-27 20:32:50 -05:00
Tak Hoffman
5a92655f5d fix: follow canonical skill source in status bundling 2026-03-27 20:30:17 -05:00
Tak Hoffman
ae9b9575c5 fix(regression): preserve gateway subagent session change metadata 2026-03-27 20:28:58 -05:00
Tak Hoffman
1cfea0af07 fix(regression): restore plugin sdk compat export 2026-03-27 20:27:53 -05:00
Tak Hoffman
f4a45071e3 fix: preserve session thread ids in agent send events 2026-03-27 20:24:35 -05:00
Tak Hoffman
7fadb4f7ff fix(regression): preserve subagent session ownership metadata 2026-03-27 20:24:15 -05:00
Tak Hoffman
5eb3ea3028 fix(regression): tolerate legacy skill source metadata 2026-03-27 20:24:15 -05:00
Tak Hoffman
39048e054d fix(regression): invalidate remote skill snapshots on disconnect 2026-03-27 20:24:15 -05:00
Tak Hoffman
2d75288738 fix(regression): export direct-dm plugin sdk subpath 2026-03-27 20:24:14 -05:00
Tak Hoffman
d2e25b03fe fix(regression): preserve external command auth context 2026-03-27 20:24:14 -05:00
Tak Hoffman
d604ce9950 fix(regression): preserve numeric session thread ids 2026-03-27 20:24:14 -05:00
Tak Hoffman
1efa81bcab fix(regression): restore imessage sdk facade targets 2026-03-27 20:24:14 -05:00
Tak Hoffman
8a687bdbd7 fix(regression): preserve spawned metadata across auto-reply reset 2026-03-27 20:24:14 -05:00
Tak Hoffman
3ec1df86fa fix(regression): restore slack probe fallback without runtime 2026-03-27 20:24:14 -05:00
Tak Hoffman
b598cdf968 fix(regression): preserve discord thread bindings for plugin commands 2026-03-27 20:24:14 -05:00
Tak Hoffman
b1eeca3b00 fix(regression): stop cross-channel plugin thread defaults 2026-03-27 20:24:14 -05:00
Tak Hoffman
835441233d fix(regression): support contracts surface in test planner 2026-03-27 20:24:14 -05:00
Tak Hoffman
9cb3ce8e1a fix(regression): restore typed provider compat tests 2026-03-27 20:24:14 -05:00
Tak Hoffman
803f60105b fix(regression): align provider flow docs with bundled compat 2026-03-27 20:24:14 -05:00
Tak Hoffman
27decb9649 fix(regression): route contract paths through test wrapper 2026-03-27 20:24:14 -05:00
Tak Hoffman
67fba9c5e1 fix(regression): align model picker with bundled compat 2026-03-27 20:24:14 -05:00
Tak Hoffman
e817b3cfbc fix(regression): align provider wizard with bundled compat 2026-03-27 20:24:14 -05:00
Peter Steinberger
50a2f67258 fix(ci): align skill fixture source info 2026-03-28 01:23:29 +00:00
Peter Steinberger
b81bf005b9 refactor: trim models-config test async wrappers 2026-03-28 01:21:56 +00:00
Tak Hoffman
1fee91e431 fix: preserve session thread ids in sessions changed events 2026-03-27 20:21:07 -05:00
Tak Hoffman
762afb1bf0 fix: preserve session thread ids in transcript event payloads 2026-03-27 20:21:07 -05:00
Tak Hoffman
07bbf50419 fix: preserve session route metadata in node event touches 2026-03-27 20:21:06 -05:00
Tak Hoffman
627d6c80f2 fix: preserve session thread ids in chat session snapshots 2026-03-27 20:21:06 -05:00
Tak Hoffman
53861607f6 fix: use origin thread metadata in tools effective context 2026-03-27 20:21:06 -05:00
Tak Hoffman
1b16a112e7 fix: keep numeric session thread ids in sessions list 2026-03-27 20:21:06 -05:00
Tak Hoffman
f0d5d7a33a fix: preserve session origin account metadata in announce routing 2026-03-27 20:21:06 -05:00
Tak Hoffman
59cd79d37f fix: use session origin thread metadata in chat routing 2026-03-27 20:21:06 -05:00
Tak Hoffman
a9e9c7cbfd fix: use session origin delivery metadata in outbound targets 2026-03-27 20:21:06 -05:00
Peter Steinberger
8222d3a83a refactor: make models-config mode resolution synchronous 2026-03-28 01:18:08 +00:00
Peter Steinberger
0ffd6b202f test: dedupe security audit and acl suites 2026-03-28 01:17:57 +00:00
Peter Steinberger
c8c669537f test: dedupe plugin contract and loader suites 2026-03-28 01:17:57 +00:00
Peter Steinberger
1adf08a19d fix: replay skill source fixture drift 2026-03-28 01:13:19 +00:00
Peter Steinberger
b9560f4685 docs: clarify legacy provider sdk compat barrels 2026-03-28 01:12:52 +00:00
Peter Steinberger
b643f92447 refactor: use main sdk barrels for model and whatsapp helpers 2026-03-28 01:10:44 +00:00
Peter Steinberger
883ff949c0 fix(ci): align skill fixture source info 2026-03-28 01:10:33 +00:00
Peter Steinberger
659fe82d31 refactor: use split provider config type in models-json test 2026-03-28 01:08:09 +00:00
Peter Steinberger
dc8486f5e6 refactor: use provider config type from split secret helper 2026-03-28 01:06:22 +00:00
ImLukeF
6c9126ec19 macOS: test gateway version normalization 2026-03-28 12:05:34 +11:00
huohua-dev
8545cbd358 fix(macos): strip "OpenClaw " prefix before parsing gateway version
`openclaw --version` outputs "OpenClaw 2026.x.y-z" but
readGatewayVersion() passed the full string to Semver.parse(),
which failed on the "OpenClaw " prefix. This caused the app to
fall back to reading package.json from a local source checkout
(~/Projects/openclaw), reporting a false version mismatch.

Strip the product name prefix before parsing so the installed
CLI version is correctly recognized.
2026-03-28 12:05:33 +11:00
Peter Steinberger
2de896524f refactor: route models-config internals through split helpers 2026-03-28 01:04:04 +00:00
Peter Steinberger
2d6f4bf6c6 refactor: split models-config provider normalization helper 2026-03-28 01:01:02 +00:00
Peter Steinberger
8ab8f2c461 fix: center skills detail modal 2026-03-28 01:00:44 +00:00
Peter Steinberger
4aa5526271 refactor: route plugin-sdk model and whatsapp facades through public barrels 2026-03-28 00:58:17 +00:00
Peter Steinberger
7a1dce307d refactor: split models-config provider policy helpers 2026-03-28 00:56:02 +00:00
Peter Steinberger
d38ec0c9c9 test: dedupe loader heartbeat and audit cases 2026-03-28 00:53:34 +00:00
Peter Steinberger
d69aedcd3e fix: replay skill source fixture drift 2026-03-28 00:52:45 +00:00
Peter Steinberger
5c52824d3e refactor: split models-config source-managed helpers 2026-03-28 00:52:20 +00:00
Peter Steinberger
7db79b04c6 refactor: split models-config provider discovery helpers 2026-03-28 00:48:30 +00:00
Peter Steinberger
fa4da0ce5d fix(ci): replay compaction and skills api drift 2026-03-28 00:47:11 +00:00
Peter Steinberger
6a039bca30 test: dedupe loader and audit suites 2026-03-28 00:46:53 +00:00
Peter Steinberger
b4fe0faf1b test: dedupe config and utility suites 2026-03-28 00:46:53 +00:00
Peter Steinberger
48eae5f327 test: isolate browser plugin cli integration 2026-03-28 00:45:57 +00:00
Peter Steinberger
8f06ed8ef5 fix: short-circuit disabled media runtime 2026-03-28 00:45:57 +00:00
Peter Steinberger
c8ad0bde08 refactor: split models-config provider secret helpers 2026-03-28 00:44:36 +00:00
Tak Hoffman
262e5c57c8 fix(ci): stabilize module-bound exact regressions (#56085)
* Adjust compaction identifier test for summary args

* Harden exec completion after child exit

* Handle SDK compaction and skill shape drift

* Stabilize Synology Chat module-bound tests

* Restore skill source compatibility shims

* Restore self-hosted provider discovery mocks
2026-03-27 19:44:15 -05:00
Peter Steinberger
ce21ef641a fix: replay compaction and skills api drift 2026-03-28 00:37:31 +00:00
Peter Steinberger
e1f300695a refactor: extract models-config api-key normalization helpers 2026-03-28 00:37:31 +00:00
Gustavo Madeira Santana
b6ead2dd3b fix(matrix): align outbound direct-room selection (#56076)
Merged via squash.

Prepared head SHA: bbd9afdd5c
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 20:35:55 -04:00
Peter Steinberger
6455606b90 refactor: extract models-config plugin catalog helpers 2026-03-28 00:33:52 +00:00
Peter Steinberger
1f24181495 fix(ci): replay compaction and skills api drift 2026-03-28 00:32:53 +00:00
Peter Steinberger
cb9c044025 refactor: extract models-config implicit provider orchestration 2026-03-28 00:30:50 +00:00
Peter Steinberger
0d12f1ab91 fix: replay compaction and skills api drift 2026-03-28 00:29:23 +00:00
Peter Steinberger
8056c5581b refactor: extract models-config provider auth helpers 2026-03-28 00:29:23 +00:00
qer
8c079a804c Plugins: clean up channel config on uninstall (#35915)
* Plugins: clean up channel config on uninstall

`openclaw plugins uninstall` only removed `plugins.*` entries but left
`channels.<id>` config behind, causing errors when the gateway
referenced a channel whose plugin no longer existed.

Now `removePluginFromConfig` also deletes the matching
`channels.<pluginId>` entry (exact match only), and the CLI
previews/reports the removal. Shared config keys like `defaults`
and `modelByChannel` are guarded from accidental removal.

* Plugins: sync uninstall preview with channel cleanup

* fix: clean up channel config on uninstall (#35915) (thanks @wbxl2000)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-27 17:28:38 -07:00
Peter Steinberger
87792c9050 test: dedupe gateway network and transcript suites 2026-03-28 00:26:55 +00:00
Peter Steinberger
fef688fb7a test: dedupe utility and config suites 2026-03-28 00:26:55 +00:00
Peter Steinberger
d8f97358d7 refactor: extract models-config provider normalization helpers 2026-03-28 00:26:23 +00:00
Peter Steinberger
4c213a5de7 fix(ci): replay compaction and skills api drift 2026-03-28 00:24:28 +00:00
Gustavo Madeira Santana
238d369a77 Plugins: add nested discovery regression test 2026-03-27 20:24:14 -04:00
Peter Steinberger
7b12de591b refactor: route models-config provider statics through shared barrel 2026-03-28 00:23:13 +00:00
Peter Steinberger
94f87d7b11 refactor: keep shared provider model sdk generic 2026-03-28 00:20:13 +00:00
Peter Steinberger
542d62ba93 fix: replay compaction and skills api drift 2026-03-28 00:17:50 +00:00
Peter Steinberger
b8069c2bd1 refactor: trim provider model compat seams 2026-03-28 00:17:28 +00:00
Peter Steinberger
78160b5f88 fix: align discord registry and runtime test helpers 2026-03-28 00:13:44 +00:00
George Zhang
6b72de77ba Revert "Plugins: sync channel uninstall cleanup" 2026-03-27 17:12:57 -07:00
Peter Steinberger
96a4df49b9 fix(ci): align compaction and skills api drift 2026-03-28 00:12:14 +00:00
ZIHANXU
29674d75fb fix: load pierre themes without json module imports (#45869)
Merged via squash.

Prepared head SHA: dd456aa32b
Co-authored-by: NickHood1984 <124482724+NickHood1984@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 20:12:11 -04:00
Peter Steinberger
8e687613b6 refactor: move stream payload compat into provider seams 2026-03-28 00:10:39 +00:00
George Zhang
958e3a4c69 Plugins: sync channel uninstall cleanup 2026-03-27 17:10:32 -07:00
Peter Steinberger
1fc7a4e952 fix(ci): type capability provider manifest mock 2026-03-28 00:08:56 +00:00
Peter Steinberger
2d8351b3b4 fix: align anthropic and skills helpers with shared sdk 2026-03-28 00:08:52 +00:00
Peter Steinberger
79c56d417b docs(changelog): add missing security release notes 2026-03-28 00:08:25 +00:00
Tak Hoffman
3dbd81e610 fix(regression): restore bundled capability provider compat 2026-03-27 19:05:58 -05:00
Peter Steinberger
d0cd645b4a style: format exec approval prompt template 2026-03-28 00:05:32 +00:00
Peter Steinberger
dd640e3c41 refactor: add focused global singleton sdk seam 2026-03-28 00:05:32 +00:00
Peter Steinberger
de7bba14cc fix(ci): align compaction and skills api drift 2026-03-28 00:04:24 +00:00
Peter Steinberger
2a98464a28 test: dedupe outbound routing and queue suites 2026-03-28 00:02:09 +00:00
Peter Steinberger
0b013bdd94 test: dedupe exec approval and system run suites 2026-03-28 00:02:09 +00:00
Gustavo Madeira Santana
378803987c fix(diffs): stage bundled runtime deps after updates (#56077)
Merged via squash.

Prepared head SHA: 2a153451de
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 20:01:14 -04:00
Peter Steinberger
13316a9118 refactor: reuse shared model prefix helper in thinking 2026-03-28 00:00:08 +00:00
Tak Hoffman
7da92cc618 fix(regression): align doctor plugin status with runtime 2026-03-27 18:59:47 -05:00
Peter Steinberger
d343b11bf1 refactor: reuse shared openai model prefix helper 2026-03-27 23:58:42 +00:00
Peter Steinberger
4b5aa6fd0b fix: refresh skill fixtures for pi-coding-agent 2026-03-27 23:58:41 +00:00
Peter Steinberger
6ba0c434ba refactor: move plugin tool routing defaults into tool context 2026-03-27 23:58:04 +00:00
Peter Steinberger
44defeb71b fix: unify plugin tool thread defaults via delivery context 2026-03-27 23:58:04 +00:00
Peter Steinberger
1c412b1ac6 fix: resolve Telegram slash command bindings from sender peer 2026-03-27 23:58:04 +00:00
Tak Hoffman
ee2220ca08 fix(regression): align plugin status with runtime compat 2026-03-27 18:55:41 -05:00
Peter Steinberger
55b9ce1c9d fix(ci): use https for libsignal git dependency 2026-03-27 23:55:13 +00:00
Peter Steinberger
0b26e4d72a refactor: split shared provider catalog sdk helpers 2026-03-27 23:55:10 +00:00
Peter Steinberger
41eb7c5056 fix(ci): align compaction and skills api drift 2026-03-27 23:52:27 +00:00
Gustavo Madeira Santana
93f9ca00dd build: bump pi packages to 0.63.1 2026-03-27 19:51:10 -04:00
Peter Steinberger
e951838c33 docs: point sdk overview at provider model shared seam 2026-03-27 23:50:04 +00:00
Peter Steinberger
adb78fa5dd fix: note ollama think=false landing (#53200) (thanks @BruceMacD) 2026-03-27 23:49:33 +00:00
Bruce MacDonald
773c57b418 fix(ollama): send think=false for thinking models when thinking is off
Ollama thinking-capable models default to think=true when the parameter
is absent. When OpenClaw has thinking set to off, the request never
included think=false, so models continued generating thinking tokens
that were then discarded by the response parser, producing empty
responses.

Wire onPayload into the Ollama stream path so payload wrappers can
mutate the request body, and add an Ollama-specific wrapper that sets
top-level think=false when thinkingLevel is off.

Fixes #46680, #50702, #50712

Co-Authored-By: SnowSky1 <126348592+snowsky1@users.noreply.github.com>
2026-03-27 23:49:33 +00:00
Peter Steinberger
3b51e6471a test: switch provider model barrel straggler imports 2026-03-27 23:48:56 +00:00
Peter Steinberger
0e3f517881 fix(ci): refresh bundled plugin metadata baselines 2026-03-27 23:47:29 +00:00
Peter Steinberger
898f3fa591 style: format compact skill test import 2026-03-27 23:47:04 +00:00
Peter Steinberger
8d95351217 fix: update skill and compaction test fixtures 2026-03-27 23:47:04 +00:00
Peter Steinberger
b39a7e8073 fix: break plugin-sdk provider barrel recursion 2026-03-27 23:47:04 +00:00
Peter Steinberger
ac68494dae fix(ci): harden discord rate-limit helpers 2026-03-27 23:43:43 +00:00
Peter Steinberger
232a96a0dc test(browser): spy tmp-dir seam in pw download test 2026-03-27 23:40:35 +00:00
Peter Steinberger
25a988c211 fix(ci): narrow browser config refresh seam 2026-03-27 23:38:56 +00:00
Gustavo Madeira Santana
5ca8be7323 matrix: guard invalid HTML entity mention labels 2026-03-27 19:37:58 -04:00
Peter Steinberger
eef2f82986 test: dedupe infra utility suites 2026-03-27 23:33:08 +00:00
Peter Steinberger
36b9ec9418 fix(ci): narrow browser logger and schema seams 2026-03-27 23:29:59 +00:00
Peter Steinberger
fc5e5f1e8e fix: resolve loader and test fallout after sdk split 2026-03-27 23:27:55 +00:00
Peter Steinberger
4ca07559ab refactor: move provider seams behind plugin sdk surfaces 2026-03-27 23:26:26 +00:00
Peter Steinberger
4f0ad16a00 fix(ci): route browser tmp path through public temp-path seam 2026-03-27 23:24:57 +00:00
Peter Steinberger
6fec75f15d test(browser): isolate auth and download mocks 2026-03-27 23:20:43 +00:00
Peter Steinberger
c720fa83bb fix(browser): narrow browser support facades 2026-03-27 23:20:24 +00:00
Peter Steinberger
a27624437e fix(ci): align skills api drift and tui keybindings 2026-03-27 23:18:31 +00:00
Nick Ludlam
5d82534af7 fix(matrix): mentions should work with displayName labels (with help from Antigravity) (#55393)
Merged via squash.

Prepared head SHA: c6df37ce14
Co-authored-by: nickludlam <7568+nickludlam@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 19:16:33 -04:00
Gustavo Madeira Santana
04458c807c lockfile: restore microsoft-foundry importer 2026-03-27 19:10:13 -04:00
Peter Steinberger
8a788e2c0c test: dedupe infra and plugin-sdk utility suites 2026-03-27 23:08:57 +00:00
private-peter
0558f2470d fix(matrix): only use 2-member DM fallback when dm refresh fails (#54890)
Merged via squash.

Prepared head SHA: e32d220ef0
Co-authored-by: private-peter <251383182+private-peter@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 19:08:40 -04:00
Gustavo Madeira Santana
11952457af Docs: document skill source precedence 2026-03-27 19:05:04 -04:00
Gustavo Madeira Santana
1fc4d7259f Agents/TUI: align with current pi APIs 2026-03-27 19:05:04 -04:00
alberthild
c7fbd51890 fix(matrix): resolve reply context body and sender for quoted messages (#55056)
Merged via squash.

Prepared head SHA: 6fd580bb03
Co-authored-by: alberthild <3729342+alberthild@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 19:03:21 -04:00
Peter Steinberger
65ad45a37f test(browser): stabilize cli runtime seams 2026-03-27 22:59:11 +00:00
esrehmki
f7934d7024 fix(matrix): pass originalFilename to saveMediaBuffer and expose path via MEDIA tag (#55692)
Merged via squash.

Prepared head SHA: a68dc0841b
Co-authored-by: esrehmki <20036971+esrehmki@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-27 18:53:52 -04:00
Peter Steinberger
a74c50c861 test: align profile env bootstrap with lazy dotenv 2026-03-27 22:48:09 +00:00
Peter Steinberger
8908f6c23b fix(ci): pin patched path-to-regexp 2026-03-27 22:44:14 +00:00
Peter Steinberger
2fc386f0df test: dedupe plugin sdk helper suites 2026-03-27 22:43:38 +00:00
Peter Steinberger
90c50fd9d8 test: stabilize extension mocks for ci shards 2026-03-27 22:40:30 +00:00
Peter Steinberger
c52f89bd60 test: dedupe helper-heavy test suites 2026-03-27 22:35:27 +00:00
Tak Hoffman
0826fb4a00 fix(regression): export plugin-sdk huggingface facade 2026-03-27 17:34:26 -05:00
Peter Steinberger
d9d5688792 test: make extension no-test coverage dynamic 2026-03-27 22:29:35 +00:00
Peter Steinberger
a5cb9ec674 fix(ci): align skills api and trim status startup 2026-03-27 22:24:54 +00:00
Tak Hoffman
4d7c6519fc fix(regression): enable owning plugins for provider onboarding 2026-03-27 17:24:09 -05:00
Peter Steinberger
7d4fab3e73 test: debrand pairing and dm policy fixtures 2026-03-27 22:18:20 +00:00
Tak Hoffman
3106ad38f2 fix(regression): preserve ACP turns without admin provenance scope 2026-03-27 17:13:53 -05:00
Peter Steinberger
a1ab0d9886 test: debrand generic auth-choice placeholders 2026-03-27 22:10:07 +00:00
Peter Steinberger
a834832d26 test: debrand final helper placeholders 2026-03-27 22:06:29 +00:00
Peter Steinberger
634db43b3f test: debrand fallback and registry pin fixtures 2026-03-27 22:05:34 +00:00
Peter Steinberger
c815bddce7 test: debrand debounce and acp lifecycle fixtures 2026-03-27 22:03:51 +00:00
Peter Steinberger
b95a81498f test: debrand policy and registry fixtures 2026-03-27 22:03:15 +00:00
Tak Hoffman
12923eb612 fix(regression): align sdk config write account lookup 2026-03-27 17:02:33 -05:00
Peter Steinberger
d27b99c6af test: debrand helper fixture ids 2026-03-27 22:01:15 +00:00
Peter Steinberger
be67c0de1d test: debrand queue retry fixtures 2026-03-27 21:58:29 +00:00
Peter Steinberger
884247f8d8 test: debrand generic setup helper fixtures 2026-03-27 21:57:58 +00:00
Tak Hoffman
5f7f914796 fix(regression): restore external phone control commands 2026-03-27 16:57:16 -05:00
Peter Steinberger
2019b649af test: debrand generic session binding fixtures 2026-03-27 21:54:56 +00:00
Peter Steinberger
a9cc830ded test: debrand generic outbound channel fixtures 2026-03-27 21:53:19 +00:00
Peter Steinberger
a50455452d test: debrand plumbing labels and restore skill compat 2026-03-27 21:50:39 +00:00
Peter Steinberger
03e7e3cd27 test: debrand generic channel fixture names 2026-03-27 21:48:05 +00:00
Peter Steinberger
f09b449ab1 fix(ci): align skills source info and compaction args 2026-03-27 21:47:03 +00:00
Peter Steinberger
76d3c67a88 test: debrand session and allowlist placeholders 2026-03-27 21:45:29 +00:00
Peter Steinberger
adb20a9fa9 test: debrand generic formatting fixtures 2026-03-27 21:44:18 +00:00
Peter Steinberger
8ae90e16fc refactor: debrand core fixtures and align skill types 2026-03-27 21:43:03 +00:00
Tak Hoffman
a0cc684d02 fix(regression): restore modelstudio sdk facade exports 2026-03-27 16:39:18 -05:00
Peter Steinberger
992b30604d refactor: move extension-owned tests to extensions 2026-03-27 21:37:09 +00:00
Peter Steinberger
d506eea076 fix(ci): restore contract plugin-sdk source loading 2026-03-27 21:33:32 +00:00
Harold Hunt
7d18799bbe Hooks: pass inbound attachment arrays to plugins (#55452)
Merged via squash.

Prepared head SHA: 062f8d0513
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-27 17:23:24 -04:00
Tak Hoffman
9134dbd252 fix(regression): fail discord startup on reconnect exhaustion 2026-03-27 16:20:02 -05:00
Tak Hoffman
c125c33724 fix(regression): honor internal provider auth for directives 2026-03-27 16:15:09 -05:00
Tak Hoffman
f41cd12b54 fix(regression): restore modelstudio facade exports 2026-03-27 16:10:42 -05:00
Tak Hoffman
85cf23a9d6 fix(regression): allow external device pair approvals 2026-03-27 16:07:54 -05:00
Tak Hoffman
eacd5ac3ef fix(regression): restore external talk voice updates 2026-03-27 16:05:22 -05:00
Tak Hoffman
1a9abb13bd fix(regression): preserve CLI continuity across chat reset 2026-03-27 16:01:37 -05:00
Peter Steinberger
b50b9b16ab fix(ci): isolate discord session-key facade 2026-03-27 20:59:39 +00:00
Peter Steinberger
c813222671 fix(ci): support discord rate limit ctor drift 2026-03-27 20:54:23 +00:00
Tak Hoffman
f8edd09a2c fix(regression): invalidate stale legacy CLI sessions 2026-03-27 15:52:47 -05:00
Peter Steinberger
c73c050276 fix(ci): align compaction and skills tests with upstream agent API 2026-03-27 20:48:48 +00:00
Peter Steinberger
03333100ba fix(ci): align discord rate limit calls and telegram test imports 2026-03-27 20:48:21 +00:00
Peter Steinberger
cb5aefb790 fix: sync plugin sdk guardrails and test drift 2026-03-27 20:47:36 +00:00
Peter Steinberger
2bdbb189bd refactor: route plugin sdk facades through extension barrels 2026-03-27 20:47:36 +00:00
Tak Hoffman
fa56682b3c fix(regression): preserve CLI bindings across session reset 2026-03-27 15:47:16 -05:00
Tak Hoffman
9446ee8ea3 fix(regression): restore Telegram fallback probe coverage 2026-03-27 15:47:16 -05:00
Jacob Tomlinson
8eaa3417c3 Config: skip nonexistent Tavily web-search migration 2026-03-27 20:45:59 +00:00
Tak Hoffman
fc570934de fix(regression): refresh Telegram probe test imports 2026-03-27 15:42:03 -05:00
Tak Hoffman
7772395618 fix(regression): isolate Telegram runtime helper tests 2026-03-27 15:42:03 -05:00
Tak Hoffman
366c1d6b9e fix(regression): tighten Telegram runtime helper coverage 2026-03-27 15:42:03 -05:00
Peter Steinberger
fa05c351a1 fix(ci): align compaction and skills tests with upstream agent API 2026-03-27 20:41:10 +00:00
Jacob Tomlinson
20c7cbbf78 Telegram: tighten media SSRF policy (#56004)
* Telegram: tighten media SSRF policy

* Telegram: restrict media downloads to configured hosts

* Telegram: preserve custom media apiRoot hosts
2026-03-27 20:39:24 +00:00
Jacob Tomlinson
511093d4b3 Discord: apply component interaction policy gates (#56014)
* Discord: apply component interaction policy gates

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* Discord: pass carbon rate limit request

* Discord: reply to blocked component interactions

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
2026-03-27 20:38:40 +00:00
Jacob Tomlinson
e403decb6e nextcloud-talk: throttle repeated webhook auth failures (#56007)
* nextcloud-talk: throttle repeated webhook auth failures

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>

* nextcloud-talk: scope webhook auth limiter per server

* nextcloud-talk: limit repeated webhook auth failures only

---------

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
2026-03-27 20:37:55 +00:00
Jacob Tomlinson
355abe5eba Discord: enforce approver checks for text approvals (#56015)
* Discord: gate text approvals by approver policy

* Discord: require approvers for plugin text approvals

* Discord: preserve legacy text approval fallback
2026-03-27 20:37:15 +00:00
Jacob Tomlinson
be00fcfccb Gateway: align chat.send reset scope checks (#56009)
* Gateway: align chat.send reset scope checks

* Gateway: tighten chat.send reset regression test

* Gateway: honor internal provider reset scope
2026-03-27 20:36:31 +00:00
Jacob Tomlinson
aa66ae1fc7 Extensions: require admin for config write commands (#56002)
* Extensions: require admin for config write commands

* Tests: cover phone control disarm auth
2026-03-27 20:35:42 +00:00
Jacob Tomlinson
e64a881ae0 Channels: preserve routed group policy (#56011) 2026-03-27 20:33:47 +00:00
Tak Hoffman
77060aa9f9 fix(regression): preserve Telegram status fallback without runtime 2026-03-27 15:33:10 -05:00
Peter Steinberger
ae7d93adc4 fix(ci): restore green check after upstream API changes 2026-03-27 20:30:35 +00:00
Peter Steinberger
41901c19bf fix: restore green check after upstream API changes 2026-03-27 20:29:18 +00:00
Peter Steinberger
79e495a627 fix: add OpenAI version attribution header 2026-03-27 20:29:18 +00:00
Jacob Tomlinson
d61f8e5672 Net: block missing IPv6 special-use ranges (#56008)
* Net: block missing IPv6 special-use ranges

* Tests: refresh public IPv6 pinning fixtures
2026-03-27 20:28:25 +00:00
Jacob Tomlinson
85777e726c Voice Call: canonicalize Plivo V3 replay key (#56003)
Co-authored-by: zsx <git@zsxsoft.com>
2026-03-27 20:27:23 +00:00
Peter Steinberger
d73dbb6753 fix: restore provider auth and build checks 2026-03-27 20:20:31 +00:00
Peter Steinberger
c28e76c490 refactor: move provider model helpers into plugins 2026-03-27 20:20:31 +00:00
Peter Steinberger
5d3d54ee36 refactor: generate plugin sdk facades 2026-03-27 20:20:31 +00:00
Peter Steinberger
888be707cf fix(hooks): avoid repo-wide format churn 2026-03-27 20:19:53 +00:00
Tak Hoffman
4430805719 Allow inherited AWS config file paths 2026-03-27 15:16:19 -05:00
Tak Hoffman
8bcab7ec6f Allow manual remote URL after trust decline 2026-03-27 15:16:19 -05:00
Tak Hoffman
c3d45fbb19 Fallback to Jiti when bun is unavailable 2026-03-27 15:16:19 -05:00
Tak Hoffman
fa89d68e7a Fix compaction safeguard request auth lookup 2026-03-27 15:16:19 -05:00
Peter Steinberger
4505987b9c style(ui): format control views 2026-03-27 20:15:13 +00:00
Jacob Tomlinson
eb6a3fca26 telegram: use live runtime helpers in channel status 2026-03-27 20:14:33 +00:00
Peter Steinberger
953a438420 fix(discord): align rate-limit error callsites 2026-03-27 20:09:15 +00:00
Peter Steinberger
49dbf64ab1 fix(core): harden bundled provider runtime surfaces 2026-03-27 20:04:53 +00:00
Jacob Tomlinson
cf10183389 plugins: disable native jiti loading under bun 2026-03-27 20:02:59 +00:00
Jacob Tomlinson
3e4222e9d4 docs: fix duplicate testing heading 2026-03-27 19:50:09 +00:00
Vincent Koc
02e3061aa7 fix(discord): stop queued reconnect exhaustion crash (#55991) 2026-03-27 12:39:38 -07:00
Peter Steinberger
496a1a35bd fix(test): stabilize line and irc extension suites 2026-03-27 19:32:57 +00:00
Byungsker
1dae6cc617 docs(agent-loop): correct default timeoutSeconds from 600s to 172800s (48h) (#55419)
* docs(agent-loop): correct default timeoutSeconds from 600s to 172800s (48h)

The default was raised to 48 hours in PR #51874 (merged 2026-03-21) to
avoid cutting off long-running ACP sessions, but the docs were not
updated at the time. Closes #55380.

* docs: remove 'Use 0 to disable' per aisle security review
2026-03-27 12:31:24 -07:00
Jacob Tomlinson
16ed9bf147 config: fall back to jiti for channel config surfaces 2026-03-27 19:27:35 +00:00
Peter Steinberger
605c9306ab fix(ci): repair extension and discord test gates 2026-03-27 19:26:25 +00:00
Jacob Tomlinson
febcb01128 discord: fix Carbon RateLimitError calls 2026-03-27 19:16:36 +00:00
Jacob Tomlinson
4d7cc6bb4f gateway: restrict node pairing approvals (#55951)
* gateway: restrict node pairing approvals

* gateway: tighten node pairing scope checks

* gateway: harden node pairing reconnects

* agents: request elevated node pairing scopes

* agents: fix node pairing approval preflight scopes
2026-03-27 19:14:16 +00:00
Jacob Tomlinson
68ceaf7a5f zalo: gate image downloads before DM auth (#55979)
* zalo: gate image downloads before DM auth

* zalo: clarify pre-download auth sentinel
2026-03-27 19:12:26 +00:00
Jacob Tomlinson
9ec44fad39 Exec approvals: reject wrapper carrier allow-always targets (#55947)
* Exec approvals: reject wrapper carrier allow-always targets

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* Tests: add shell wrapper carrier follow-up assertion

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
2026-03-27 19:07:47 +00:00
Nimrod Gutman
7ce2670043 fix(discord): update carbon beta (#55980)
* fix(discord): update carbon beta

* fix: update carbon beta (#55980) (thanks @ngutman)
2026-03-27 22:06:20 +03:00
Jacob Tomlinson
824e16f9dd fix(media): require fs access for dynamic local roots (#55946)
* fix(media): require fs access for dynamic local roots

* fix(media): tighten fs root expansion policy

* fix(media): align fs root expansion with effective policy
2026-03-27 19:06:02 +00:00
Jacob Tomlinson
c603123528 fix(gateway): require admin for persisted verbose defaults (#55916)
* fix(gateway): require admin for verbose persistence

* gateway: tighten verbose persistence follow-ups
2026-03-27 19:04:02 +00:00
joshavant
55cd272fe1 changelog correction
Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-27 12:02:44 -07:00
Jacob Tomlinson
7a801cc451 Gateway: disconnect revoked device sessions (#55952)
* Gateway: disconnect revoked device sessions

* Gateway: normalize device disconnect targets

* Gateway: scope token revoke disconnects by role

* Gateway: respond before disconnecting sessions
2026-03-27 19:01:26 +00:00
Jacob Tomlinson
fef1b1918c SDK: break channel plugin import cycle 2026-03-27 19:00:57 +00:00
Jacob Tomlinson
80d1e8a11a fal: guard image fetches (#55948)
* fal: guard image fetches

* fal: isolate guarded fetch tests

* fal: trust configured relay hosts
2026-03-27 18:59:25 +00:00
Peter Steinberger
2f13758f42 test: stabilize extension ci mocks 2026-03-27 18:55:58 +00:00
Jacob Tomlinson
4ee4960de2 Pairing: forward caller scopes during approval (#55950)
* Pairing: require caller scopes on approvals

* Gateway: reject forbidden silent pairing results
2026-03-27 18:55:33 +00:00
Jacob Tomlinson
2e23d44491 tests(feishu): reload chat tool after mock reset 2026-03-27 18:53:39 +00:00
Jacob Tomlinson
6eb82fba3c Infra: block additional host exec env keys (#55977) 2026-03-27 18:50:37 +00:00
Jacob Tomlinson
fdbcfced84 Agents: enforce session status visibility (#55904)
* Agents: enforce session_status visibility

* Agents: preserve sandboxed session_status visibility checks
2026-03-27 18:49:24 +00:00
Jacob Tomlinson
b7b3c806b4 fix(compaction): guard legacy model registry auth lookup 2026-03-27 18:44:54 +00:00
Jacob Tomlinson
d6affb17d8 CLI: confirm discovered remote gateways before saving config (#55895)
* CLI: require trust confirmation for discovered remote gateways

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* CLI: clear discovery pin when remote URL changes

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
2026-03-27 18:43:42 +00:00
Peter Steinberger
c9d68fb9c2 fix: repair ci test and loader regressions 2026-03-27 18:41:47 +00:00
glitch
3cec3bd48b fix(memory): share embedding providers across plugin runtime splits (#55945)
Merged via squash.

Prepared head SHA: e913806211
Co-authored-by: glitch418x <189487110+glitch418x@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-27 21:40:19 +03:00
Jacob Tomlinson
78e2f3d66d Exec: tighten jq safe-bin env checks (#55905) 2026-03-27 18:37:31 +00:00
Jacob Tomlinson
c774db9a1f fix(compaction): pass summary headers before abort signal 2026-03-27 18:35:31 +00:00
Jacob Tomlinson
25210317b8 fix(skills): adapt skill source metadata API 2026-03-27 18:28:45 +00:00
Peter Steinberger
694619caaf fix(runtime): narrow discord binding targets after rebase 2026-03-27 18:15:40 +00:00
Peter Steinberger
6e107b8857 fix(runtime): stabilize provider and channel runtime tests 2026-03-27 18:15:40 +00:00
Peter Steinberger
52ef2ef790 fix(agents): align compaction and skill metadata APIs 2026-03-27 18:15:40 +00:00
Peter Steinberger
894f57a4ce style(ui): apply formatter output 2026-03-27 18:15:40 +00:00
Peter Steinberger
69e67a764d refactor(feishu): remove docx table lint suppressions 2026-03-27 18:15:40 +00:00
Jakub Rusz
8f44bd6426 fix(ollama): emit streaming events for text content during generation (#53891)
The Ollama stream function requested `stream: true` from the API but
accumulated all content chunks internally, emitting only a single `done`
event at the end. This prevented downstream consumers (block streaming
pipeline, typing indicators, draft stream) from receiving incremental
text updates during generation.

Emit the full `start → text_start → text_delta* → text_end → done`
event sequence matching the AssistantMessageEvent contract used by
Anthropic, OpenAI, and Google providers. Each `text_delta` carries both
the incremental `delta` and an accumulated `partial` snapshot.

Tool-call-only responses (no text content) continue to emit only the
`done` event, preserving backward compatibility.

---------

Signed-off-by: Jakub Rusz <jrusz@proton.me>
Co-authored-by: Claude <claude-opus-4-6> <noreply@anthropic.com>
Co-authored-by: Bruce MacDonald <brucewmacdonald@gmail.com>
2026-03-27 11:12:09 -07:00
Peter Steinberger
1086acf3c2 fix: repair latest-main ci gate 2026-03-27 17:57:23 +00:00
Radek Sienkiewicz
47ae562cc9 Docs: unify link audit entrypoint (#55912)
Merged via squash.

Prepared head SHA: 6b1ccb9f1f
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-03-27 18:31:19 +01:00
Peter Steinberger
d35f37a58c style: format ui views 2026-03-27 17:23:40 +00:00
Peter Steinberger
6f7579803b refactor: route memory doctor suggestions through plugin metadata 2026-03-27 17:23:40 +00:00
Peter Steinberger
2d26f2d876 refactor: move legacy auth choice aliases into plugin manifests 2026-03-27 17:23:40 +00:00
Peter Steinberger
e25f634d50 refactor: move oauth profile repair metadata into providers 2026-03-27 17:23:40 +00:00
Peter Steinberger
570bfb655f refactor: route bundled provider catalog hooks through plugins 2026-03-27 17:23:40 +00:00
Peter Steinberger
910cb9f1af refactor: simplify provider auth storage setters 2026-03-27 17:23:40 +00:00
Peter Steinberger
67f609ea9a refactor: remove core provider model definitions compat 2026-03-27 17:23:40 +00:00
Peter Steinberger
3628451aa3 refactor: move provider default model refs into extension apis 2026-03-27 17:23:40 +00:00
Peter Steinberger
94780dde5d refactor: reduce provider auth storage boilerplate 2026-03-27 17:23:40 +00:00
Peter Steinberger
b568ccee7c refactor: route litellm sdk through public api barrel 2026-03-27 17:23:40 +00:00
Peter Steinberger
3702409427 refactor: trim remaining plugin sdk extension seams 2026-03-27 17:23:40 +00:00
Peter Steinberger
e599cb26de refactor: route provider catalogs through public api barrels 2026-03-27 17:23:40 +00:00
Altay
4693813503 fix: re-format exec-approval view 2026-03-27 19:51:36 +03:00
Altay
c352a018f1 chore: add lockfile entry for extensions 2026-03-27 19:50:46 +03:00
Peter Steinberger
ef1784d264 refactor: move bundled plugin policy into manifests 2026-03-27 16:40:27 +00:00
Peter Steinberger
ed055f44ae refactor: route plugin runtime through bundled seams 2026-03-27 16:40:27 +00:00
Peter Steinberger
e425056aa3 refactor: route plugin runtime media through sdk wrappers 2026-03-27 16:39:42 +00:00
Peter Steinberger
425032ed4d refactor: move public artifact metadata into plugins 2026-03-27 16:39:42 +00:00
Peter Steinberger
07df59287a refactor: share plugin capability provider resolution 2026-03-27 16:39:41 +00:00
Peter Steinberger
f1503bd5c7 refactor: route bundled capability providers through plugin runtime 2026-03-27 16:39:41 +00:00
Peter Steinberger
8d054e7892 test: move shared seams into contract suites 2026-03-27 16:33:53 +00:00
Peter Steinberger
09f2832670 test: split contract seams from unit lane 2026-03-27 16:28:23 +00:00
Peter Steinberger
89267f4273 test: finish reply seam cleanup 2026-03-27 16:22:27 +00:00
Radek Sienkiewicz
ce5b0577d4 docs: fix Browserless and broken doc links (#55881)
Merged via squash.

Prepared head SHA: 528d04e070
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-03-27 17:11:57 +01:00
Peter Steinberger
8ff39007c4 test: remove moved core duplicates 2026-03-27 16:08:57 +00:00
Peter Steinberger
cd92549119 test: split extension-owned core coverage 2026-03-27 16:08:57 +00:00
Josh Avant
6ade9c474c feat(hooks): add async requireApproval to before_tool_call (#55339)
* Plugins: add native ask dialog for before_tool_call hooks

Extend the before_tool_call plugin hook with a requireApproval return field
that pauses agent execution and waits for real user approval via channels
(Telegram, Discord, /approve command) instead of relying on the agent to
cooperate with a soft block.

- Add requireApproval field to PluginHookBeforeToolCallResult with id, title,
  description, severity, timeout, and timeoutBehavior options
- Extend runModifyingHook merge callback to receive hook registration so
  mergers can stamp pluginId; always invoke merger even for the first result
- Make ExecApprovalManager generic so it can be reused for plugin approvals
- Add plugin.approval.request/waitDecision/resolve gateway methods with
  schemas, scope guards, and broadcast events
- Handle requireApproval in pi-tools via two-phase gateway RPC with fallback
  to soft block when the gateway is unavailable
- Extend the exec approval forwarder with plugin approval message builders
  and forwarding methods
- Update /approve command to fall back to plugin.approval.resolve when exec
  approval lookup fails
- Document before_tool_call requireApproval in hooks docs and unified
  /approve behavior in exec-approvals docs

* Plugins: simplify plugin approval code

- Extract mergeParamsWithApprovalOverrides helper to deduplicate param
  merge logic in before_tool_call hook handling
- Use idiomatic conditional spread syntax in toolContext construction
- Extract callApprovalMethod helper in /approve command to eliminate
  duplicated callGateway calls
- Simplify plugin approval schema by removing unnecessary Type.Union
  with Type.Null on optional fields
- Extract normalizeTrimmedString helper for turn source field trimming

* Tests: add plugin approval wiring and /approve fallback coverage

Fix 3 broken assertions expecting old "Exec approval" message text.
Add tests for the /approve command's exec→plugin fallback path,
plugin approval method registration and scope authorization, and
handler factory key verification.

* UI: wire plugin approval events into the exec approval overlay

Handle plugin.approval.requested and plugin.approval.resolved gateway
events by extending the existing exec approval queue with a kind
discriminator. Plugin approvals reuse the same overlay, queue management,
and expiry timer, with branched rendering for plugin-specific content
(title, description, severity). The decision handler routes resolve calls
to the correct gateway method based on kind.

* fix: read plugin approval fields from nested request payload

The gateway broadcasts plugin approval payloads with title, description,
severity, pluginId, agentId, and sessionKey nested inside the request
object (PluginApprovalRequestPayload), not at the top level. Fix the
parser to read from the correct location so the overlay actually appears.

* feat: invoke plugin onResolution callback after approval decision

Adds onResolution to the requireApproval type and invokes it after
the user resolves the approval dialog, enabling plugins to react to
allow-always vs allow-once decisions.

* docs: add onResolution callback to requireApproval hook documentation

* test: fix /approve assertion for unified approval response text

* docs: regenerate plugin SDK API baseline

* docs: add changelog entry for plugin approval hooks

* fix: harden plugin approval hook reliability

- Add APPROVAL_NOT_FOUND error code so /approve fallback uses structured
  matching instead of fragile string comparison
- Check block before requireApproval so higher-priority plugin blocks
  cannot be overridden by a lower-priority approval
- Race waitDecision against abort signal so users are not stuck waiting
  for the full approval timeout after cancelling a run
- Use null consistently for missing pluginDescription instead of
  converting to undefined
- Add comments explaining the +10s timeout buffer on gateway RPCs

* docs: document block > requireApproval precedence in hooks

* fix: address Phase 1 critical correctness issues for plugin approval hooks

- Fix timeout-allow param bug: return merged hook params instead of
  original params when timeoutBehavior is "allow", preventing security
  plugins from having their parameter rewrites silently discarded.

- Host-generate approval IDs: remove plugin-provided id field from the
  requireApproval type, gateway request, and protocol schema. Server
  always generates IDs via randomUUID() to prevent forged/predictable
  ID attacks.

- Define onResolution semantics: add PluginApprovalResolutions constants
  and PluginApprovalResolution type. onResolution callback now fires on
  every exit path (allow, deny, timeout, abort, gateway error, no-ID).
  Decision branching uses constants instead of hard-coded strings.

- Fix pre-existing test infrastructure issues: bypass CJS mock cache for
  getGlobalHookRunner global singleton, reset gateway mock between tests,
  fix hook merger priority ordering in block+requireApproval test.

* fix: tighten plugin approval schema and add kind-prefixed IDs

Harden the plugin approval request schema: restrict severity to
enum (info|warning|critical), cap timeoutMs at 600s, limit title
to 80 chars and description to 256 chars. Prefix plugin approval
IDs with `plugin:` so /approve routing can distinguish them from
exec approvals deterministically instead of relying on fallback.

* fix: address remaining PR feedback (Phases 1-3 source changes)

* chore: regenerate baselines and protocol artifacts

* fix: exclude requesting connection from approval-client availability check

hasExecApprovalClients() counted the backend connection that issued
the plugin.approval.request RPC as an approval client, preventing
the no-approval-route fast path from firing in headless setups and
causing 120s stalls. Pass the caller's connId so it is skipped.
Applied to both plugin and exec approval handlers.

* Approvals: complete Discord parity and compatibility fallback

* Hooks: make plugin approval onResolution non-blocking

* Hooks: freeze params after approval owner is selected

* Gateway: harden plugin approval request/decision flow

* Discord/Telegram: fix plugin approval delivery parity

* Approvals: fix Telegram plugin approval edge cases

* Auto-reply: enforce Telegram plugin approval approvers

* Approvals: harden Telegram and plugin resolve policies

* Agents: static-import gateway approval call and fix e2e mock loading

* Auto-reply: restore /approve Telegram import boundary

* Approvals: fail closed on no-route and neutralize Discord mentions

* docs: refresh generated config and plugin API baselines

---------

Co-authored-by: Václav Belák <vaclav.belak@gendigital.com>
2026-03-27 09:06:40 -07:00
Peter Steinberger
351a931a62 fix(ci): restore runtime-api guardrails 2026-03-27 15:56:54 +00:00
Sally O'Malley
df5b9ef0c6 update podman setup and docs (#55388)
* update podman setup and docs

Signed-off-by: sallyom <somalley@redhat.com>

* podman: persist runtime env defaults

Co-authored-by: albertxos <kickban3000@gmail.com>
Signed-off-by: sallyom <somalley@redhat.com>

* podman: harden env and path handling, other setup updates

Signed-off-by: sallyom <somalley@redhat.com>

* podman: allow symlinked home path components

Signed-off-by: sallyom <somalley@redhat.com>

* update podman docs

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: albertxos <kickban3000@gmail.com>
2026-03-27 11:47:35 -04:00
Ayaan Zaidi
5e8db468ff fix(agents): preserve embedded auth on HTTP fallback 2026-03-27 21:15:15 +05:30
Peter Steinberger
9098e948ac fix(ci): route extension tests through public test bridges 2026-03-27 15:20:01 +00:00
Peter Steinberger
833636f0b2 style(ui): normalize control-ui formatting 2026-03-27 15:15:40 +00:00
Peter Steinberger
6a0f9afc4e style: normalize test and sdk formatting 2026-03-27 15:15:04 +00:00
Peter Steinberger
8ddeada97d test: move extension-owned coverage into plugins 2026-03-27 15:11:33 +00:00
Peter Steinberger
97297049e7 fix(ci): restore boundary and test seams 2026-03-27 15:08:33 +00:00
Ayaan Zaidi
454f094c36 fix: avoid source imports in cli startup metadata build 2026-03-27 20:31:28 +05:30
junpei.o
be0e994cf0 feat(plugins): expose runId in agent hook context (#54265) 2026-03-27 10:47:13 -04:00
Peter Steinberger
87dddb818d fix(ci): restore plugin runtime boundaries 2026-03-27 14:38:40 +00:00
bottenbenny
f9b8499bf6 fix(chat): send button should use system theme variables (#55075)
Merged via squash.

Prepared head SHA: eb3e197874
Co-authored-by: bottenbenny <270688955+bottenbenny@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-27 15:12:19 +01:00
Peter Steinberger
66a2e72bee fix: restore CI runtime seams 2026-03-27 14:07:01 +00:00
Ping
a6f5e57f46 fix(plugins): apply bundled allowlist compat in plugin status report (#55267)
* fix(plugins): apply bundled allowlist compat in plugin status report

`buildPluginStatusReport` (used by `openclaw plugins list` and
`openclaw doctor`) was calling `loadOpenClawPlugins` without applying
`withBundledPluginAllowlistCompat`. When `plugins.allow` is set, the
allowlist check in `resolveEffectiveEnableState` runs before the
bundled-default-enable check, causing all bundled plugins not explicitly
in the allowlist to be reported as "disabled".

The gateway runtime already applies this compat via
`providers.runtime.ts`, so the actual loaded state differs from what
CLI diagnostics report.

Apply the same `withBundledPluginAllowlistCompat` transform so the
status report matches gateway runtime behavior.

* add regression test for bundled allowlist compat wiring

Address review feedback: the previous mocks were identity stubs that
did not exercise the compat wiring. Now the mocks are spies, and a new
test verifies that:
1. loadPluginManifestRegistry is called to discover bundled plugin IDs
2. withBundledPluginAllowlistCompat receives only bundled IDs (not workspace)
3. loadOpenClawPlugins receives the compat-adjusted config

* scope compat to bundled providers only (address codex review)

Use resolveBundledProviderCompatPluginIds instead of injecting all
bundled plugin IDs. This matches the runtime compat surface in
providers.runtime.ts — non-provider bundled plugins (device-pair,
phone-control, etc.) are not auto-added to the allowlist, keeping
the status report consistent with gateway startup behavior.
2026-03-27 10:00:25 -04:00
Tak Hoffman
c2ca99aa0b fix(test-planner): keep exit fallback timer referenced 2026-03-27 09:00:09 -05:00
Val Alexander
bb932beeac Media: respect umask and clean failed downloads 2026-03-27 08:55:06 -05:00
Val Alexander
206c29514c Media: enforce file mode after writes 2026-03-27 08:55:06 -05:00
助爪
b1c982bb2d fix(agents): fail over and sanitize Codex server_error payloads (#42892)
Merged via squash.

Prepared head SHA: 6db9a5f02d
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-27 16:51:39 +03:00
Peter Steinberger
546a1aad98 refactor: replace plugin-sdk dist env hacks with loader option 2026-03-27 13:46:17 +00:00
Peter Steinberger
ad89fa669c fix: unstick provider contract tests 2026-03-27 13:46:17 +00:00
Peter Steinberger
b5a8d5a230 fix: stabilize plugin-sdk test loading 2026-03-27 13:46:17 +00:00
Peter Steinberger
9d10a2e242 refactor: shrink remaining test seam reach-ins 2026-03-27 13:46:17 +00:00
Peter Steinberger
f217a10780 refactor: route more runtime-boundary tests through public seams 2026-03-27 13:46:17 +00:00
Peter Steinberger
9917f3b3a1 refactor: route ollama sdk through public barrels 2026-03-27 13:46:17 +00:00
Peter Steinberger
858b1dffb8 refactor: route zalouser payload mocks through test api 2026-03-27 13:46:17 +00:00
Peter Steinberger
b70b99d46d refactor: route telegram gateway test through test api 2026-03-27 13:46:17 +00:00
Peter Steinberger
e42b4afd39 refactor: route reply plumbing test through slack entry 2026-03-27 13:46:17 +00:00
Peter Steinberger
d0c77c9dfd refactor: narrow outbound payload runtime mock reach-ins 2026-03-27 13:46:17 +00:00
Peter Steinberger
169bf6adba refactor: route outbound payload tests through extension test seams 2026-03-27 13:46:17 +00:00
Peter Steinberger
c7b4c34e89 refactor: route provider test seams through extension barrels 2026-03-27 13:46:17 +00:00
Peter Steinberger
02d9f0d631 refactor: route exec approval surface test through extension entrypoints 2026-03-27 13:46:17 +00:00
Peter Steinberger
4019671331 refactor: add runtime-boundary plugin test seams 2026-03-27 13:46:17 +00:00
Peter Steinberger
c2b28753e7 refactor: route more test seams through public plugin APIs 2026-03-27 13:46:17 +00:00
Peter Steinberger
4d630b7e92 refactor: expose dm policy test seams 2026-03-27 13:46:17 +00:00
Peter Steinberger
a79ef1591a refactor: trim health test extension mocks 2026-03-27 13:46:17 +00:00
Peter Steinberger
45849eb757 refactor: remove remaining core extension reach-ins 2026-03-27 13:46:16 +00:00
Peter Steinberger
fc3246d8fd refactor: add public channel contract test seams 2026-03-27 13:46:16 +00:00
Peter Steinberger
04bde8c2b1 refactor: route session-binding contract tests through public seams 2026-03-27 13:46:16 +00:00
Peter Steinberger
8d39fe5a76 refactor: route channel contract tests through public barrels 2026-03-27 13:46:16 +00:00
Peter Steinberger
051d6f9342 refactor: remove onboarding gateway compat shims 2026-03-27 13:46:16 +00:00
Peter Steinberger
7999577ce1 refactor: route plugin sdk through extension barrels 2026-03-27 13:46:16 +00:00
Peter Steinberger
a10763e118 refactor: generate bundled channel seams 2026-03-27 13:46:16 +00:00
Peter Steinberger
9a775aa59c refactor: continue plugin seam cleanup 2026-03-27 13:46:16 +00:00
Tak Hoffman
4c8ed2ce46 test(planner): force real timers in executor fallback 2026-03-27 08:40:48 -05:00
Tak Hoffman
398af90a22 fix(ci): makin it green 2026-03-27 08:26:49 -05:00
lurebat
4cf783b7c1 fix(skills): honor OPENAI_BASE_URL in whisper api skill (#55597)
* fix(skills): honor OPENAI_BASE_URL in whisper api skill

* Update skills/openai-whisper-api/SKILL.md

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

---------

Co-authored-by: Asaf (via Bruh) <asaf@asafshq.win>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-27 09:23:26 -04:00
Tak Hoffman
45535ff433 dev: speed up local check loop 2026-03-27 07:56:41 -05:00
Peter Steinberger
bcfddcc768 refactor: pluginize litellm auth onboarding 2026-03-27 12:26:01 +00:00
Peter Steinberger
324cddee4c fix: resolve bundled plugins from running CLI 2026-03-27 12:26:01 +00:00
Peter Steinberger
ac2c2ac954 fix: stop test-parallel from waiting forever on child close 2026-03-27 12:20:51 +00:00
Peter Steinberger
f625a0b106 style: apply current oxfmt output to ui views 2026-03-27 12:03:25 +00:00
Peter Steinberger
7dd196ed74 fix: apply live model switches during active retries 2026-03-27 12:01:55 +00:00
Peter Steinberger
d746690be5 ci: align bun artifact upload with node24-safe action 2026-03-27 11:36:53 +00:00
Anmol Ahuja
c40884d306 Prefer non-user writeable paths (#54346)
* infra: trust system binary roots

* infra: isolate windows install root overrides

* infra: narrow windows reg lookup

* browser: restore windows executable comments

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-03-27 11:29:32 +00:00
mappel-nv
9d58f9e24f Replace killProcessTree references to shell-utils with process/kill-tree (#55213)
* Replace killProcessTree references to shell-utils with process/kill-tree

* Address grace timeout comment

* Align with existing process kill behavior

* bash: fail stop without pid

* bash: lazy-load kill tree on stop

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-03-27 11:25:56 +00:00
Shakker
d80b67124b docs: add changelog entries for #55732 and #55733 2026-03-27 11:09:11 +00:00
Shakker
c259ff7e01 docs: add changelog entry for #55730 2026-03-27 11:03:51 +00:00
Shakker
58cdcf74c7 fix(tui): validate activation slash commands 2026-03-27 11:03:26 +00:00
Shakker
14c63ca42a fix(tui): prune chat log system messages atomically 2026-03-27 11:03:26 +00:00
oliviareid-svg
32a3733dbe fix(google): strip empty required arrays from tool schemas for Gemini (#52106)
Merged via squash.

Prepared head SHA: 2ec59c1332
Co-authored-by: oliviareid-svg <269669958+oliviareid-svg@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-27 14:00:14 +03:00
Shakker
8fa62985b9 fix: preserve tui local auth with url overrides 2026-03-27 10:32:13 +00:00
Shakker
f1de00c107 fix: keep tui out of browser origin checks 2026-03-27 10:32:13 +00:00
Shakker
2b96569e2d fix: add dedicated tui gateway client auth 2026-03-27 10:32:13 +00:00
Shakker
3d609b112e fix: repair local control ui auth on quickstart reruns 2026-03-27 10:32:13 +00:00
Shakker
8270baa1d0 fix: allow local quickstart control ui without pairing 2026-03-27 10:32:13 +00:00
Shakker
37538072e6 fix: clarify no-daemon onboarding gateway checks 2026-03-27 10:32:13 +00:00
Shakker
e765daaed3 fix: show resolved gateway port in setup wizard 2026-03-27 10:32:13 +00:00
Jacob Tomlinson
4b9542716c Gateway: require verified scope for chat provenance (#55700)
* Gateway: require verified scope for chat provenance

* Gateway: clarify chat provenance auth gate
2026-03-27 10:13:34 +00:00
Jacob Tomlinson
83da3cfe31 infra: unwrap script wrapper approval targets (#55685)
* infra: unwrap script wrapper approvals

* infra: handle script short option values

* infra: gate script wrapper unwrapping by platform

* infra: narrow script wrapper option parsing
2026-03-27 10:05:35 +00:00
Jacob Tomlinson
cb5f7e201f gateway: cap concurrent pre-auth websocket upgrades (#55294)
* gateway: cap concurrent pre-auth websocket upgrades

* gateway: release pre-auth budget on failed upgrades

* gateway: scope pre-auth budgets to trusted client ip

* gateway: reject upgrades before ws handlers attach

* gateway: cap preauth budget for unknown client ip
2026-03-27 09:55:27 +00:00
Jacob Tomlinson
76411b2afc Agents: block protected gateway config writes (#55682)
* Agents: block protected gateway config writes

* Agents: tighten gateway config guard coverage

* Agents: guard migrated exec config aliases
2026-03-27 09:42:15 +00:00
Ted Li
4d5f762b7d fix: fall back from synthetic tool accounts (#55627) (thanks @MonkeyLeeT)
* feishu: fall back from synthetic tool accounts

* feishu: validate implicit tool accounts by config id

* fix: fall back from synthetic tool accounts (#55627) (thanks @MonkeyLeeT)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-27 15:09:36 +05:30
Jacob Tomlinson
2d80dbfeba fix(gateway): require read scope for models http (#55683) 2026-03-27 09:23:04 +00:00
Ayaan Zaidi
3a7cf5364f test(cleanup): isolate session lock queue coverage 2026-03-27 13:50:02 +05:30
Ayaan Zaidi
d6662e2aa7 fix(test): stabilize windows lock and cache paths 2026-03-27 13:29:15 +05:30
Peter Steinberger
a30dae3c71 fix: honor test planner cache paths by target platform 2026-03-27 07:53:57 +00:00
Peter Steinberger
1b77e6fd72 test: remove duplicate voice-call stdout assertion 2026-03-27 07:38:56 +00:00
Peter Steinberger
e1235ca7b4 test: fix voice-call cli stdout assertions 2026-03-27 07:37:41 +00:00
Ayaan Zaidi
53304ff704 test(voice-call): capture cli stdout 2026-03-27 13:01:32 +05:30
Peter Steinberger
9322481075 fix: route ollama helpers through plugin sdk 2026-03-27 07:26:41 +00:00
Ayaan Zaidi
ae72977076 fix(agents): restore ollama public seam 2026-03-27 12:46:34 +05:30
Tak Hoffman
23fae00fad Reduce script logging suppressions and Feishu any casts 2026-03-27 02:12:56 -05:00
Tak Hoffman
f5643544c2 Reduce lint suppressions in core tests and runtime 2026-03-27 02:11:26 -05:00
Peter Steinberger
7c00cc9d0a test: fix feishu batch insert test syntax 2026-03-27 07:10:55 +00:00
Peter Steinberger
854b71a4b0 test: fix feishu test typings 2026-03-27 07:10:55 +00:00
heavenlost
3cbd4de95c fix(gateway): restore loopback detail probes and identity fallback (#51087)
Merged via squash.

Prepared head SHA: f8a66ffde2
Co-authored-by: heavenlost <70937055+heavenlost@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-27 08:09:41 +01:00
Tak Hoffman
6f92148da9 fix(test-planner): shrink local extension batches on constrained hosts 2026-03-27 01:59:46 -05:00
Ayaan Zaidi
cfddce4196 fix(feishu): restore tsgo test typing 2026-03-27 12:13:59 +05:30
Peter Steinberger
a3e73daa6b refactor: remove ollama legacy shims 2026-03-27 06:38:23 +00:00
Ayaan Zaidi
bd2c208689 refactor(mattermost): type config seams 2026-03-27 11:59:02 +05:30
Peter Steinberger
e58170ddc1 build: refresh plugin sdk api baseline 2026-03-27 06:26:21 +00:00
Ayaan Zaidi
f2b2b12af4 test(feishu): type bot interaction fixtures 2026-03-27 11:54:23 +05:30
Ayaan Zaidi
59a25978dd test(feishu): type reply lifecycle fixtures 2026-03-27 11:54:23 +05:30
Ayaan Zaidi
6ad50ce474 test(feishu): type tool harness fixtures 2026-03-27 11:54:23 +05:30
Ayaan Zaidi
1042710e3b test(feishu): type policy fixtures 2026-03-27 11:54:23 +05:30
Ayaan Zaidi
b23dc5073f test(feishu): type basic fixtures 2026-03-27 11:54:23 +05:30
Tak Hoffman
9a7c8e186e test(plugin-sdk): refresh matrix runtime api guardrail 2026-03-27 01:21:58 -05:00
Peter Steinberger
a8066ad96d fix: align skills and compaction api drift 2026-03-27 06:18:41 +00:00
Tak Hoffman
599d880c49 build: refresh plugin sdk api baseline 2026-03-27 01:12:57 -05:00
Peter Steinberger
a729eab6ee build: refresh plugin sdk api baseline 2026-03-27 06:06:37 +00:00
Peter Steinberger
f86765cba3 style: refresh ui formatting 2026-03-27 06:06:37 +00:00
Peter Steinberger
e3e9a56b02 fix: export matrix account helpers in runtime api 2026-03-27 06:06:37 +00:00
Peter Steinberger
8923fb8766 style: normalize ui formatting 2026-03-27 06:06:37 +00:00
Ayaan Zaidi
9db096a98f refactor(feishu): remove docx explicit-any escapes 2026-03-27 11:34:35 +05:30
Ayaan Zaidi
571d4d52e9 refactor(feishu): remove stale explicit-any escapes 2026-03-27 11:34:35 +05:30
Ayaan Zaidi
f248fc8f86 refactor(feishu): type runtime payload seams 2026-03-27 11:34:35 +05:30
Ayaan Zaidi
9ce2dbe9aa refactor(feishu): type sender drive and typing helpers 2026-03-27 11:34:35 +05:30
Tak Hoffman
f799cd6a14 test(agents): align compaction safeguard auth mock 2026-03-27 01:03:05 -05:00
Tak Hoffman
55ab98dc40 fix(agents): adapt compaction and skill source types 2026-03-27 01:02:22 -05:00
Tak Hoffman
417024f9ad style(ui): apply formatting cleanup 2026-03-27 00:57:46 -05:00
Tak Hoffman
716c93f624 docs(plugin-sdk): refresh generated API baseline 2026-03-27 00:53:50 -05:00
Tak Hoffman
2b586b423a test(google): spy on image-generation auth surface 2026-03-27 00:51:38 -05:00
Tak Hoffman
04d01984ef fix(build): make bundled runtime-deps staging incremental 2026-03-27 00:51:38 -05:00
Ayaan Zaidi
bd4ecbfe49 refactor(feishu): type docx and media sdk seams 2026-03-27 11:15:07 +05:30
Peter Steinberger
2f979e9be0 test: fix memory-core host type import 2026-03-27 05:38:58 +00:00
Peter Steinberger
4c27c90fc2 refactor: finish moving provider runtime into extensions 2026-03-27 05:38:58 +00:00
Peter Steinberger
25879be46a test: stub summary transport in tts tests 2026-03-27 05:38:58 +00:00
Peter Steinberger
64bf80d4d5 refactor: move provider runtime into extensions 2026-03-27 05:38:58 +00:00
Peter Steinberger
53a3922e1c style: normalize ui slash executor formatting 2026-03-27 05:38:22 +00:00
Saurabh
8ab6891d97 fix: remove dead .status assignment and add 503 error test 2026-03-27 05:38:22 +00:00
Saurabh
517ae6ea14 fix: trigger model fallback on HTTP 503 Service Unavailable (#55178) 2026-03-27 05:38:22 +00:00
lurebat
5d91b68af3 fix(whatsapp): exclude quoted message mentionedJids from mention detection (#52711)
Merged via squash.

Prepared head SHA: 2ac325f615
Co-authored-by: lurebat <154669821+lurebat@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-27 02:35:49 -03:00
Ayaan Zaidi
85d5e4360d fix(skills): use skill sourceInfo 2026-03-27 10:59:07 +05:30
Ayaan Zaidi
39fae14c72 fix(agents): adapt compaction request auth 2026-03-27 10:59:07 +05:30
Ayaan Zaidi
9368f834c0 style(ui): format view templates 2026-03-27 10:59:07 +05:30
Ayaan Zaidi
f804da9444 test(mattermost): remove double-cast test helpers 2026-03-27 10:59:07 +05:30
Tak Hoffman
75534f7a47 docs(config): refresh bundled channel config metadata 2026-03-27 00:25:43 -05:00
Peter Steinberger
ee12f24760 fix: restore ci compatibility 2026-03-27 05:23:50 +00:00
Tak Hoffman
c13d6dbf55 docs: define YOLO mode for main verification 2026-03-27 00:11:15 -05:00
Tak Hoffman
ed2798417e check: restore bundled channel config metadata gate 2026-03-27 00:11:15 -05:00
Peter Steinberger
fbd8990e78 fix: resolve rebase fallout in runtime tests 2026-03-27 05:07:50 +00:00
Peter Steinberger
ffa2a47c58 test: stabilize slow contract and integration suites 2026-03-27 05:07:50 +00:00
Peter Steinberger
a9e241dacb fix: align runtime types with upstream changes 2026-03-27 05:07:50 +00:00
Peter Steinberger
a1f995053e refactor: migrate more boundary parsing to zod 2026-03-27 05:07:50 +00:00
Ayaan Zaidi
137a56194e test(mattermost): remove untyped mocks 2026-03-27 10:31:08 +05:30
Ayaan Zaidi
e2d0a808e0 refactor(mattermost): type action params 2026-03-27 10:31:08 +05:30
Marcus Castro
38adeb888c fix: align Skill consumers with sourceInfo → source rename 2026-03-27 01:49:58 -03:00
Marcus Castro
2942df6b9f fix: align compaction call sites with upstream API drift 2026-03-27 01:49:58 -03:00
Peter Steinberger
8cf1e46a94 style: format ui sources 2026-03-27 04:45:17 +00:00
Peter Steinberger
3557bce827 fix: adapt to upstream agent api changes 2026-03-27 04:45:17 +00:00
Peter Steinberger
a4b77ad33f refactor: shortcut bundled provider contract fixtures 2026-03-27 04:44:43 +00:00
Peter Steinberger
17203d0af9 fix: stabilize codex oauth refresh tests 2026-03-27 04:44:43 +00:00
Peter Steinberger
4629ab3d8a refactor: move model picker logic into flow module 2026-03-27 04:44:43 +00:00
Ayaan Zaidi
06820b6daf refactor(mattermost): define post schema once 2026-03-27 10:08:29 +05:30
Ayaan Zaidi
ca9659ffb0 style(mattermost): format websocket monitor 2026-03-27 10:04:23 +05:30
Ayaan Zaidi
51d851e092 fix(skills): use skill sourceInfo 2026-03-27 09:57:02 +05:30
Ayaan Zaidi
b1d853d88b fix(agents): adapt compaction request auth 2026-03-27 09:56:35 +05:30
Ayaan Zaidi
8b13710c09 refactor(plugin-sdk): expose zod subpath 2026-03-27 09:55:47 +05:30
Peter Steinberger
70184d0a5e fix: compaction API drift + Skill sourceInfo→source migration
- compaction.ts: drop removed 'headers' param from generateSummary call
- compaction.retry.test.ts: align test call with new generateSummary signature
- compaction-safeguard.ts: replace getApiKeyAndHeaders with getApiKey (upstream removed)
- Migrate all Skill sourceInfo.source → flat source field across agents, cli, security
- Update 6 test files to match new Skill shape
2026-03-27 04:23:39 +00:00
Peter Steinberger
d8a1808bd6 fix: nextcloud-talk + mattermost type errors
- nextcloud-talk setup-core: cast input to NextcloudSetupInput before accessing .secret/.secretFile/.baseUrl
- mattermost monitor-websocket: add intermediate 'as unknown' for ZodRecord→ZodType<MattermostPost> cast
2026-03-27 04:23:39 +00:00
Tak Hoffman
2b55708f40 docs(config): refresh generated baselines 2026-03-26 23:19:57 -05:00
Tak Hoffman
5eee793669 test(config): align legacy routing snapshot expectation 2026-03-26 23:17:43 -05:00
huntharo
4262abe05d test: lower prepare unit-fast batch target 2026-03-27 00:12:49 -04:00
Peter Steinberger
eebce9e9c7 refactor: move memory host into sdk package 2026-03-27 04:12:04 +00:00
Peter Steinberger
490b2f881c fix(ci): restore codex oauth refresh fallback 2026-03-27 04:08:12 +00:00
Ayaan Zaidi
b99b16d71e test: activate openrouter media registry in vision test 2026-03-27 09:36:25 +05:30
Ayaan Zaidi
513df9fdb0 build: refresh bundled plugin metadata 2026-03-27 09:36:25 +05:30
Neerav Makwana
4604d252b2 Media: allow active OpenRouter image models 2026-03-27 09:36:25 +05:30
Tak Hoffman
962cc740a0 fix: keep session settings in sessions list 2026-03-26 22:48:39 -05:00
Peter Steinberger
ef56d79a6a refactor: collapse zod setup validators 2026-03-27 03:48:15 +00:00
Tak Hoffman
e25965ed4a fix: keep session thread ids in sessions list 2026-03-26 22:43:05 -05:00
Tak Hoffman
a2cc0630d2 fix: use session origin provider in subagent queue routing 2026-03-26 22:42:45 -05:00
Tak Hoffman
6e5bc5647a fix: use session origin provider in chat delivery routing 2026-03-26 22:42:45 -05:00
Tak Hoffman
8e4115947b fix: use session origin provider for reset overrides 2026-03-26 22:42:45 -05:00
Tak Hoffman
5783c2e070 fix: use session origin provider in status queue settings 2026-03-26 22:42:45 -05:00
Tak Hoffman
bd5fe92c94 fix: use session origin provider in sessions list 2026-03-26 22:42:45 -05:00
Peter Steinberger
e6c5ce136e refactor: share zod setup validators across channels 2026-03-27 03:41:40 +00:00
Peter Steinberger
1d1f36adff refactor: parse extension event payloads with zod 2026-03-27 03:41:40 +00:00
Peter Steinberger
35b132884c refactor: add zod helpers for json file readers 2026-03-27 03:41:40 +00:00
Tak Hoffman
5b4669632a Avoid stale sessions_send reply carryover 2026-03-26 22:40:50 -05:00
Tak Hoffman
6ef6e80abe Avoid stale subagent reply carryover 2026-03-26 22:40:50 -05:00
Tak Hoffman
d81b5fc792 Fix status subagent ownership parity 2026-03-26 22:40:50 -05:00
SnowSky1
7016659dbe fix: land bundled plugin doctor migration (#55054) (thanks @SnowSky1)
* fix(doctor): migrate legacy bundled plugin load paths

* fix(doctor): preserve unknown plugin path entries

* fix: derive bundled plugin legacy paths from actual directory names

* fix: land bundled plugin doctor migration (#55054) (thanks @SnowSky1)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-27 09:05:34 +05:30
Peter Steinberger
a9b982c954 refactor: remove memory-core engine barrel 2026-03-27 03:35:00 +00:00
Tak Hoffman
3e121edf20 fix(msteams): normalize memory store conversation ids 2026-03-26 22:29:46 -05:00
Peter Steinberger
be6b841334 fix: align skill and compaction API usage 2026-03-27 03:27:51 +00:00
Tak Hoffman
0235cca58a fix: preserve explicit reset transcript paths 2026-03-26 22:27:09 -05:00
Peter Steinberger
b15d9eb565 fix(agents): restore compaction headers typing 2026-03-27 03:19:59 +00:00
Peter Steinberger
158c4c1f4f fix(repo): restore gate after upstream drift 2026-03-27 02:58:14 +00:00
Peter Steinberger
f6de4cd766 refactor: remove memory-core runtime barrel 2026-03-27 02:54:23 +00:00
Peter Steinberger
c9ab095099 refactor: deduplicate plugin config schemas 2026-03-27 02:53:08 +00:00
Peter Steinberger
a331270f8a fix: restore green build after upstream API drift 2026-03-27 02:49:53 +00:00
Peter Steinberger
bd6c7969ea refactor: extract memory host sdk package 2026-03-27 02:49:33 +00:00
Peter Steinberger
dff3ca2018 fix: stabilize ci after deps refresh 2026-03-27 02:44:05 +00:00
Kinfey
c959ac3a25 fix: WSL2 Ollama networking and provider discovery diagnostics (#55435)
- Fix Ollama stream handling for WSL2 environments
- Update undici global dispatcher for WSL2 networking compatibility
- Adjust provider discovery configuration
- Add WSL2 networking tests
2026-03-26 21:41:05 -05:00
Peter Steinberger
9df9bd436e refactor(config): dedupe legacy migration metadata 2026-03-27 02:37:47 +00:00
Peter Steinberger
66e7e29219 refactor(config): simplify version and allowed-value resolution 2026-03-27 02:37:17 +00:00
Peter Steinberger
417b3dd5e0 refactor: move channel prefer-over metadata into manifests 2026-03-27 02:36:56 +00:00
Peter Steinberger
b96f5d94db chore(telegram): downgrade default network logs 2026-03-27 02:29:32 +00:00
Peter Steinberger
465f830bcd fix(config): support uri formats in schema validation 2026-03-27 02:29:32 +00:00
Peter Steinberger
0b94382930 fix(plugins): prefer runtime version for host compatibility 2026-03-27 02:29:32 +00:00
Peter Steinberger
dd098596cf refactor: collapse bundled channel metadata into plugin manifests 2026-03-27 02:29:19 +00:00
Peter Steinberger
ea60bc01b9 refactor(config): drop stale legacy migrations 2026-03-27 02:29:08 +00:00
Peter Steinberger
10527ff8a3 build: refresh deps and vitest cache lanes 2026-03-27 02:26:07 +00:00
Peter Steinberger
b49accc273 test: add websocket replay planning coverage 2026-03-27 02:16:01 +00:00
Peter Steinberger
86bac4ee2a refactor: split openai websocket message conversion 2026-03-27 02:16:01 +00:00
Vincent Koc
fa2a318f40 Align ACPX built-in agent registry with latest acpx (#55476)
* Add Cursor CLI to ACP allowedAgents

- acpx: add cursor to ACPX_BUILTIN_AGENT_COMMANDS (agent acp)
- docs: add cursor to acp-agents harness list and allowedAgents example

Fixes #28321

Made-with: Cursor

* ACP Cursor: add to acp-router skill, system-prompt, and schema help

- acp-router SKILL: add Cursor to description, intent, agentId mapping,
  harness aliases, and built-in adapter commands (agent acp)
- system-prompt: add cursor to ACP harness example
- schema.help: add cursor to runtime.acp.agent example

Fixes #28321

Made-with: Cursor

* fix(acpx): align built-in agent registry with latest acpx

---------

Co-authored-by: Rob MacDonald <rob@robmacdonald.com>
2026-03-26 19:15:17 -07:00
Peter Steinberger
e9f54ca815 docs: update parallels smoke guidance 2026-03-27 02:15:15 +00:00
Peter Steinberger
1ff1679984 test: harden parallels windows smoke polling 2026-03-27 02:15:15 +00:00
Tak Hoffman
3e5d86384e fix: initialize reset transcript files 2026-03-26 21:12:36 -05:00
Peter Steinberger
77d15841d7 refactor: move manifest legacy migration into doctor 2026-03-27 02:09:58 +00:00
Tak Hoffman
5404b0eaa6 fix(msteams): preserve timezone on memory upsert 2026-03-26 21:06:16 -05:00
Tak Hoffman
708b9339a5 fix: assign reset transcript paths to new sessions 2026-03-26 21:05:21 -05:00
Peter Steinberger
14b3360c22 chore: bump versions to 2026.3.26 2026-03-27 02:03:22 +00:00
Peter Steinberger
7a35bca2ec refactor: make memory embedding adapters generic 2026-03-27 02:02:24 +00:00
Peter Steinberger
42be3fb059 refactor: collapse manifest contract mirrors 2026-03-27 02:01:59 +00:00
Peter Steinberger
b666ce692f refactor: extract openai ws replay helpers 2026-03-27 02:00:51 +00:00
Peter Steinberger
60a8dd95de refactor: split compaction safeguard quality helpers 2026-03-27 02:00:09 +00:00
Peter Steinberger
40bd36e35d refactor: move channel config metadata into plugin-owned manifests 2026-03-27 01:59:30 +00:00
Tak Hoffman
e1ff753790 fix: persist create transcript paths in session entries 2026-03-26 20:54:15 -05:00
Peter Steinberger
ca01595699 refactor: split tool display exec parsing 2026-03-27 01:50:32 +00:00
Peter Steinberger
d7b61228e2 fix: tighten openai ws reasoning replay (#53856) 2026-03-27 01:49:55 +00:00
Peter Steinberger
ad21d84940 docs: add apply_patch changelog note 2026-03-27 01:46:30 +00:00
Peter Steinberger
ab6ddf7245 refactor: slim plugin sdk provider entrypoints 2026-03-27 01:45:53 +00:00
Peter Steinberger
485bfe95ed fix: clean bundled contract metadata follow-ups 2026-03-27 01:45:53 +00:00
Peter Steinberger
ba7804df50 refactor: derive bundled contracts from extension manifests 2026-03-27 01:45:52 +00:00
Peter Steinberger
b75be09144 refactor: split subagent announce delivery helpers 2026-03-27 01:44:59 +00:00
Tak Hoffman
320c0d65a1 fix: keep session settings in chat lifecycle events 2026-03-26 20:38:35 -05:00
Peter Steinberger
3fdd7c9e00 refactor: split compaction hooks 2026-03-27 01:36:13 +00:00
Peter Steinberger
f862685ed8 refactor: split subagent registry lifecycle 2026-03-27 01:33:13 +00:00
MiloStack
b33ad4d7cb fix: guarantee heartbeat timer re-arm with try/finally (#52270)
Merged via squash.

Prepared head SHA: cd0bcc2fb8
Co-authored-by: MiloStack <265805734+MiloStack@users.noreply.github.com>
Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com>
Reviewed-by: @grp06
2026-03-26 18:29:33 -07:00
Peter Steinberger
cfbef8035d refactor: split subagent run manager 2026-03-27 01:26:07 +00:00
Peter Steinberger
18dc98b00e refactor: split embedded run auth controller 2026-03-27 01:21:10 +00:00
Peter Steinberger
f3b152e0d9 refactor: split channel setup into shared flow modules 2026-03-27 01:17:39 +00:00
Peter Steinberger
7d6d642cb8 refactor: move doctor orchestration into flow contributions 2026-03-27 01:17:39 +00:00
Peter Steinberger
23aded30d8 refactor: add provider and search flow contributions 2026-03-27 01:17:39 +00:00
Peter Steinberger
5e35e6a95f fix: lazy-load zca-js at the zalouser runtime boundary 2026-03-27 01:14:42 +00:00
Peter Steinberger
b9c60fd37a fix: default and gate apply_patch like write 2026-03-27 01:14:42 +00:00
Tak Hoffman
c326083ad8 fix: keep session settings in gateway live events 2026-03-26 20:11:13 -05:00
Tak Hoffman
48ff617169 fix: keep fast mode in gateway session rows 2026-03-26 20:11:13 -05:00
Peter Steinberger
ba60154826 fix: unify upload-file message actions 2026-03-27 01:04:01 +00:00
Peter Steinberger
046a950877 refactor: split agent command execution helpers 2026-03-27 01:02:40 +00:00
Peter Steinberger
6fd1725a06 fix: preserve ws reasoning replay (#53856) (thanks @xujingchen1996) 2026-03-26 17:53:59 -07:00
scoootscooob
cc359d4c9d fix: use runtime model and per-agent thinking defaults in status (thanks @scoootscooob, @xaeon2026, @ysfbsf) (#55425)
Merged via squash.

Prepared head SHA: 061d7c7ac0
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-26 17:49:21 -07:00
Peter Steinberger
c9556c257e docs: clarify memory plugin adapter ids 2026-03-27 00:47:01 +00:00
Peter Steinberger
dbf78de7c6 refactor: move memory engine behind plugin adapters 2026-03-27 00:47:01 +00:00
Peter Steinberger
aed6283faa refactor: split embedded run setup helpers 2026-03-27 00:46:01 +00:00
Peter Steinberger
d4da878d64 fix: preserve plugin sdk api baseline source link 2026-03-27 00:44:24 +00:00
Peter Steinberger
7de494fcec test: stabilize discord monitor ci isolation 2026-03-27 00:44:24 +00:00
Peter Steinberger
89e6b91b89 fix: decouple moonshot stream wrappers from provider runtime 2026-03-27 00:40:51 +00:00
Peter Steinberger
770c462c47 refactor: split subagent registry helpers 2026-03-27 00:35:41 +00:00
R. Desmond
fa0835dd32 test(agents): cover undersized model dispatch guard (#55369) 2026-03-26 20:30:20 -04:00
Peter Steinberger
5a98a1dbe2 refactor: split embedded run helpers 2026-03-27 00:27:49 +00:00
Peter Steinberger
540b98b23f refactor: split subagent announce output helpers 2026-03-27 00:23:32 +00:00
Peter Steinberger
0d9e4f20d5 refactor: split embedded attempt helpers 2026-03-27 00:23:32 +00:00
Peter Steinberger
48ae976333 refactor: split cli runner pipeline 2026-03-27 00:19:24 +00:00
Peter Steinberger
4329c93f85 test: wire discord monitor runtime seams 2026-03-27 00:05:49 +00:00
Craig Allan-McWilliams
984f98be95 Fix: treat HTTP 500 as a transient failover error (#55332)
HTTP 500 (Internal Server Error) was not triggering model fallback,
causing agents to fail outright instead of trying the next candidate.
This is inconsistent with TRANSIENT_HTTP_ERROR_CODES which already
includes 500. Aligns the direct status check with that constant.

Co-authored-by: Craig McWilliams <craigamcw@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:05:15 -04:00
Peter Steinberger
da845ce598 test: align discord lifecycle shutdown expectations 2026-03-27 00:03:00 +00:00
Peter Steinberger
2f43c6b334 refactor: split discord monitor startup and lifecycle 2026-03-27 00:03:00 +00:00
rustam
e3cd209889 chore: remove stale README-header.png packlist entry (#55325) 2026-03-26 16:59:36 -07:00
Coy Geek
8e285d112d fix(cr-mbx-feishu-encryptkey-config-redaction-bypass): apply security fix (#53414)
Generated by staged fix workflow.
2026-03-26 19:58:37 -04:00
Saurabh
afc649255c fix: match guild-level entries in Discord exec allowlist (#55175) 2026-03-26 16:56:58 -07:00
Peter Steinberger
bdeb7d859b test: stabilize discord monitor ci mocks 2026-03-26 23:54:59 +00:00
Marko Jak
b8ff152a98 fix(cli): isolate claude MCP config 2026-03-26 16:52:17 -07:00
Peter Steinberger
85b169c453 fix: clamp copilot auth refresh overflow (#55360) (thanks @michael-abdo) 2026-03-26 16:48:06 -07:00
Peter Steinberger
f0c1057f68 fix: restore reveal-to-edit raw config flow 2026-03-26 23:45:10 +00:00
Peter Steinberger
d25c4fd6c5 test: tighten discord lifecycle gateway mocks 2026-03-26 23:44:43 +00:00
Peter Steinberger
4726593d6d test: refresh planner batching expectations 2026-03-26 23:44:43 +00:00
Peter Steinberger
96a44d979d fix: route memory test harness through plugin sdk 2026-03-26 23:44:43 +00:00
felear2022
623f4d3056 fix: use stream-json output for Claude CLI backend to prevent watchdog timeouts
The Claude CLI backend uses `--output-format json`, which produces no
stdout until the entire request completes. When session context is large
(100K+ tokens) or API response is slow, the no-output watchdog timer
(max 180s for resume sessions) kills the process before it finishes,
resulting in "CLI produced no output for 180s and was terminated" errors.

Switch to `--output-format stream-json --verbose` so Claude CLI emits
NDJSON events throughout processing (init, assistant, rate_limit, result).
Each event resets the watchdog timer, which is the intended behavior —
the watchdog detects truly stuck processes, not slow-but-progressing ones.

Changes:
- cli-backends.ts: `json` → `stream-json --verbose`, `output: "jsonl"`
- helpers.ts: teach parseCliJsonl to extract text from Claude's
  `{"type":"result","result":"..."}` NDJSON line

Note: `--verbose` is required for stream-json in `-p` (print) mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:39:15 -07:00
kenantan32
4ad7d51c01 fix: preserve CLI session ID in text output mode
When the CLI backend output mode is "text", sessionId was hardcoded to
undefined. This caused the fallback chain to store the OpenClaw internal
UUID as the CLI session ID. On resume, --resume was called with the
wrong UUID, resulting in "No conversation found with session ID".

Return resolvedSessionId instead of undefined so the correct CLI session
ID is persisted and resume works correctly.
2026-03-26 16:34:02 -07:00
Peter Steinberger
e46e655451 test: restore memory test seams (#55324) (thanks @joelnishanth) 2026-03-26 16:33:43 -07:00
joelnishanth
5b85d0efa4 discord: fix stale-socket reconnect crash from uncaught reconnect-exhausted error 2026-03-26 16:33:43 -07:00
dhi13man
9f8c4efa9b fix(agents): use claude-cli backend in tools-disabled regression test
codex-cli lacks systemPromptArg, so the system prompt is never
serialized into argv — making the not-toContain assertion pass
vacuously even on pre-fix code. Switch to claude-cli which defines
systemPromptArg ("--append-system-prompt") and add a positive
assertion that the user-supplied prompt IS present in argv.

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 16:30:19 -07:00
dhi13man
99f0ea8d43 fix(agents): remove unconditional "Tools are disabled" prompt injection in CLI runner
`runCliAgent()` unconditionally appended "Tools are disabled in this
session. Do not call tools." to `extraSystemPrompt` for every CLI
backend session. The intent was to prevent the LLM from calling
OpenClaw's embedded API tools (since CLI backends manage their own
tools natively). However, CLI agents like Claude Code interpret this
text as a blanket prohibition on ALL tools, including their own native
Bash, Read, and Write tools.

This caused silent failures across cron jobs, group chats, and DM
sessions when using any CLI backend: the agent would see the injected
text in the system prompt and refuse to execute tools, returning text
responses instead. Cron jobs reported `lastStatus: "ok"` despite the
agent failing to run scripts.

The fix removes the hardcoded string entirely. CLI backends already
receive `tools: []` (no OpenClaw embedded tools in the API call), so
the text was redundant at best.

Closes #44135

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 16:30:19 -07:00
Peter Steinberger
16565020a1 refactor: finish browser test path cleanup 2026-03-26 23:28:46 +00:00
Peter Steinberger
0ef2a9c8b5 refactor: remove core browser test duplicates 2026-03-26 23:28:34 +00:00
Peter Steinberger
9a7ceceffa refactor: move browser tests into plugin 2026-03-26 23:26:37 +00:00
Peter Steinberger
22348914cf refactor: centralize discord gateway ownership 2026-03-26 23:25:27 +00:00
Peter Steinberger
01bcbcf8d5 refactor: require legacy config migration on read 2026-03-26 23:23:47 +00:00
Peter Steinberger
cad83db8b2 refactor: move memory engine into memory plugin 2026-03-26 23:20:35 +00:00
Peter Steinberger
0e182dd3e1 refactor: share top-level setup dm policies 2026-03-26 23:20:26 +00:00
Peter Steinberger
abf95c5f99 refactor: share build copy script helpers 2026-03-26 23:20:26 +00:00
Peter Steinberger
0106b0488a refactor: share config section card rendering 2026-03-26 23:20:26 +00:00
Peter Steinberger
4890656d9d refactor: share matrix state file path helper 2026-03-26 23:20:26 +00:00
Peter Steinberger
bfad32aa16 refactor: share directory config listers 2026-03-26 23:20:26 +00:00
Peter Steinberger
4151b48d6c style(browser): format profiles service test 2026-03-26 23:18:57 +00:00
Peter Steinberger
8eeccb116d test(planner): refresh extension batch expectations 2026-03-26 23:16:22 +00:00
4736 changed files with 291223 additions and 102908 deletions

View File

@@ -1,380 +0,0 @@
---
description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
---
# OpenClaw Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
## Quick Reference
```bash
# Check divergence status
git fetch upstream && git rev-list --left-right --count main...upstream/main
# Full sync (rebase preferred)
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
# Check for Swift 6.2 issues after sync
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
```
---
## Step 1: Assess Divergence
```bash
git fetch upstream
git log --oneline --left-right main...upstream/main | head -20
```
This shows:
- `<` = your local commits (ahead)
- `>` = upstream commits you're missing (behind)
**Decision point:**
- Few local commits, many upstream → **Rebase** (cleaner history)
- Many local commits or shared branch → **Merge** (preserves history)
---
## Step 2A: Rebase Strategy (Preferred)
Replays your commits on top of upstream. Results in linear history.
```bash
# Ensure working tree is clean
git status
# Rebase onto upstream
git rebase upstream/main
```
### Handling Rebase Conflicts
```bash
# When conflicts occur:
# 1. Fix conflicts in the listed files
# 2. Stage resolved files
git add <resolved-files>
# 3. Continue rebase
git rebase --continue
# If a commit is no longer needed (already in upstream):
git rebase --skip
# To abort and return to original state:
git rebase --abort
```
### Common Conflict Patterns
| File | Resolution |
| ---------------- | ------------------------------------------------ |
| `package.json` | Take upstream deps, keep local scripts if needed |
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
| `*.patch` files | Usually take upstream version |
| Source files | Merge logic carefully, prefer upstream structure |
---
## Step 2B: Merge Strategy (Alternative)
Preserves all history with a merge commit.
```bash
git merge upstream/main --no-edit
```
Resolve conflicts same as rebase, then:
```bash
git add <resolved-files>
git commit
```
---
## Step 3: Rebuild Everything
After sync completes:
```bash
# Install dependencies (regenerates lock if needed)
pnpm install
# Build TypeScript
pnpm build
# Build UI assets
pnpm ui:build
# Run diagnostics
pnpm clawdbot doctor
```
---
## Step 4: Rebuild macOS App
```bash
# Full rebuild, sign, and launch
./scripts/restart-mac.sh
# Or just package without restart
pnpm mac:package
```
### Install to /Applications
```bash
# Kill running app
pkill -x "OpenClaw" || true
# Move old version
mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
# Install new build
cp -R dist/OpenClaw.app /Applications/
# Launch
open /Applications/OpenClaw.app
```
---
## Step 4A: Verify macOS App & Agent
After rebuilding the macOS app, always verify it works correctly:
```bash
# Check gateway health
pnpm clawdbot health
# Verify no zombie processes
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
# Test agent functionality by sending a verification message
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
# Confirm the message was received on Telegram
# (Check your Telegram chat with the bot)
```
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
---
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
### Analyze-Mode Investigation
```bash
# Gather context with parallel agents
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
### Common Swift 6.2 Fixes
**FileManager.default Deprecation:**
```bash
# Search for deprecated usage
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
# Replace with proper initialization
# OLD: FileManager.default
# NEW: FileManager()
```
**Thread.isMainThread Deprecation:**
```bash
# Search for deprecated usage
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
# Replace with modern concurrency check
# OLD: Thread.isMainThread
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
```
### Peekaboo Submodule Fixes
```bash
# Check Peekaboo for concurrency issues
cd src/canvas-host/a2ui
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
# Fix and rebuild submodule
cd /Volumes/Main SSD/Developer/clawdis
pnpm canvas:a2ui:bundle
```
### macOS App Concurrency Fixes
```bash
# Check macOS app for issues
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
# Clean and rebuild after fixes
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Model Configuration Updates
If upstream introduced new model configurations:
```bash
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update openclaw.json with fallback chains
# Add model fallback configurations as needed
```
---
## Step 6: Verify & Push
```bash
# Verify everything works
pnpm clawdbot health
pnpm test
# Push (force required after rebase)
git push origin main --force-with-lease
# Or regular push after merge
git push origin main
```
---
## Troubleshooting
### Build Fails After Sync
```bash
# Clean and rebuild
rm -rf node_modules dist
pnpm install
pnpm build
```
### Type Errors (Bun/Node Incompatibility)
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
### macOS App Crashes on Launch
Usually resource bundle mismatch. Full rebuild required:
```bash
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Patch Failures
```bash
# Check patch status
pnpm install 2>&1 | grep -i patch
# If patches fail, they may need updating for new dep versions
# Check patches/ directory against package.json patchedDependencies
```
### Swift 6.2 / macOS 26 SDK Build Failures
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
**Search-Mode Investigation:**
```bash
# Exhaustive search for deprecated APIs
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
**Quick Fix Commands:**
```bash
# Find all affected files
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
# Replace FileManager.default with FileManager()
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
# For Thread.isMainThread, need manual review of each usage
grep -rn "Thread\.isMainThread" --include="*.swift" .
```
**Rebuild After Fixes:**
```bash
# Clean all build artifacts
rm -rf apps/macos/.build apps/macos/.swiftpm
rm -rf src/canvas-host/a2ui/.build
# Rebuild Peekaboo bundle
pnpm canvas:a2ui:bundle
# Full macOS rebuild
./scripts/restart-mac.sh
```
---
## Automation Script
Save as `scripts/sync-upstream.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Fetching upstream..."
git fetch upstream
echo "==> Current divergence:"
git rev-list --left-right --count main...upstream/main
echo "==> Rebasing onto upstream/main..."
git rebase upstream/main
echo "==> Installing dependencies..."
pnpm install
echo "==> Building..."
pnpm build
pnpm ui:build
echo "==> Running doctor..."
pnpm clawdbot doctor
echo "==> Rebuilding macOS app..."
./scripts/restart-mac.sh
echo "==> Verifying gateway health..."
pnpm clawdbot health
echo "==> Checking for Swift 6.2 compatibility issues..."
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
echo " Run manual fixes or use analyze-mode investigation"
else
echo "✅ No obvious Swift deprecation issues found"
fi
echo "==> Testing agent functionality..."
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
```

View File

@@ -16,6 +16,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
- If the workflow installs OpenClaw from a repo checkout instead of the site installer/npm release, finish by installing a real guest CLI shim and verifying it in a fresh guest shell. `pnpm openclaw ...` inside the repo is not enough for handoff parity.
- On macOS guests, prefer a user-global install plus a stable PATH-visible shim:
@@ -32,6 +33,8 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
- The npm-update wrapper now prints per-lane progress from the nested log files. If a lane still looks stuck, inspect the nested logs in `runDir` first (`macos-fresh.log`, `windows-fresh.log`, `linux-fresh.log`, `macos-update.log`, `windows-update.log`, `linux-update.log`) instead of assuming the outer wrapper hung.
- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `/tmp/openclaw-parallels-npm-update.*`.
## CLI invocation footgun
@@ -43,8 +46,10 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- When ref-mode onboarding stores `OPENAI_API_KEY` as an env secret ref, the post-onboard agent verification should also export `OPENAI_API_KEY` for the guest command. The gateway can still reject with pairing-required and fall back to embedded execution, and that fallback needs the env-backed credential available in the shell.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
@@ -59,6 +64,9 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
- The Windows upgrade smoke lane should restart the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`, or the old process can keep the previous gateway token and fail `gateway-health` with `unauthorized: gateway token mismatch`.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.

View File

@@ -17,7 +17,7 @@ Use this skill for release and publish-time workflow. Keep ordinary development
## Keep release channel naming aligned
- `stable`: tagged releases only, with npm dist-tag `latest`
- `stable`: tagged releases only, published to npm `latest` and then mirrored onto npm `beta` unless `beta` already points at a newer prerelease
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`

View File

@@ -1,11 +1,11 @@
---
name: openclaw-test-heap-leaks
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, triage likely transformed-module retention versus likely runtime leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available. Treat snapshot-name deltas as triage evidence, not proof, until retainers or dominators support the call.
## Workflow
@@ -14,19 +14,23 @@ Use this skill for test-memory investigations. Do not guess from RSS alone when
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
- If the report is about a specific shard or worker budget, preserve that shape.
- Before you analyze snapshots, identify the real lane names from `[test-parallel] start ...` lines or `pnpm test --plan`. Do not assume a single `unit-fast` lane; local plans often split into `unit-fast-batch-*`.
2. Wait for repeated snapshots before concluding anything.
- Take at least two intervals from the same lane.
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
- Compare snapshots from the same PID inside the real lane directory such as `.tmp/heapsnap/unit-fast-batch-2/`.
- Use `.agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
- If the helper suggests transformed-module retention, confirm the top entries in DevTools retainers/dominators before calling it solved.
3. Classify the growth before choosing a fix.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as likely retained module graph growth in long-lived workers.
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
- If the names are ambiguous, stop short of a confident label and inspect retainers/dominators in DevTools for the top deltas.
4. Fix the right layer.
- For retained transformed-module growth in shared workers:
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
- For likely retained transformed-module growth in shared workers:
- Prefer timing and hotspot-driven scheduling fixes first. Check whether the file is already represented in `test/fixtures/test-timings.unit.json` and whether `scripts/test-update-memory-hotspots.mjs` should refresh the measured hotspot manifest before hand-editing behavior overrides.
- Move hotspot files out of the real shared lane by updating `test/fixtures/test-parallel.behavior.json` only when timing-driven peeling is insufficient.
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
- For real leaks:
@@ -40,24 +44,24 @@ Use this skill for test-memory investigations. Do not guess from RSS alone when
## Heuristics
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
- Do not call everything a leak. In this repo, large `unit-fast` or `unit-fast-batch-*` growth can be a worker-lifetime problem rather than an application object leak.
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition, then confirm ambiguous calls with retainer evidence.
## Snapshot Comparison
- Direct comparison:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
- Auto-select earliest/latest snapshots per PID within one lane:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast-batch-2`
- Useful flags:
- `--top 40`
- `--min-kb 32`
- `--pid 16133`
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak. If the names alone do not settle it, open the same snapshot pair in DevTools and inspect retainers/dominators for the top rows before declaring root cause.
## Output Expectations
@@ -66,6 +70,6 @@ When using this skill, report:
- The exact reproduce command.
- Which lane and PID were compared.
- The dominant retained object families from the snapshot delta.
- Whether the issue is a real leak or shared-worker retained module growth.
- Whether the issue is a likely real leak or likely shared-worker retained module growth, plus whether retainers/dominators confirmed it.
- The concrete fix or impact-reduction patch.
- What you verified, and what snapshot overhead prevented you from verifying.

View File

@@ -64,6 +64,243 @@ function parseArgs(argv) {
return options;
}
class JsonStreamScanner {
constructor(filePath) {
this.stream = fs.createReadStream(filePath, {
encoding: "utf8",
highWaterMark: 1024 * 1024,
});
this.iterator = this.stream[Symbol.asyncIterator]();
this.buffer = "";
this.offset = 0;
this.done = false;
}
compactBuffer() {
if (this.offset > 65536) {
this.buffer = this.buffer.slice(this.offset);
this.offset = 0;
}
}
async ensureAvailable(count = 1) {
while (!this.done && this.buffer.length - this.offset < count) {
const next = await this.iterator.next();
if (next.done) {
this.done = true;
break;
}
this.buffer += next.value;
}
}
async peek() {
await this.ensureAvailable(1);
return this.buffer[this.offset] ?? null;
}
async next() {
await this.ensureAvailable(1);
if (this.offset >= this.buffer.length) {
return null;
}
const char = this.buffer[this.offset];
this.offset += 1;
this.compactBuffer();
return char;
}
async skipWhitespace() {
while (true) {
const char = await this.peek();
if (char === null || !/\s/u.test(char)) {
return;
}
await this.next();
}
}
async expectChar(expected) {
const char = await this.next();
if (char !== expected) {
fail(`Expected ${expected} but found ${char ?? "<eof>"}`);
}
}
async find(sequence) {
let matched = 0;
while (true) {
const char = await this.next();
if (char === null) {
fail(`Could not find ${sequence}`);
}
if (char === sequence[matched]) {
matched += 1;
if (matched === sequence.length) {
return;
}
continue;
}
matched = char === sequence[0] ? 1 : 0;
if (matched === sequence.length) {
return;
}
}
}
async readBalancedObject() {
const start = await this.next();
if (start !== "{") {
fail(`Expected { but found ${start ?? "<eof>"}`);
}
let text = "{";
let depth = 1;
let inString = false;
let escaped = false;
while (depth > 0) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON object");
}
text += char;
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
return text;
}
async parseNumberArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let token = "";
let index = 0;
const flush = () => {
if (token.length === 0) {
fail("Unexpected empty number token");
}
const value = Number.parseInt(token, 10);
if (!Number.isFinite(value)) {
fail(`Invalid numeric token: ${token}`);
}
onValue(value, index);
index += 1;
token = "";
};
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading number array");
}
if (char === "]") {
flush();
return;
}
if (char === ",") {
flush();
continue;
}
if (/\s/u.test(char)) {
continue;
}
token += char;
}
}
async readJsonString() {
await this.expectChar('"');
let value = "";
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON string");
}
if (char === '"') {
return value;
}
if (char !== "\\") {
value += char;
continue;
}
const escaped = await this.next();
if (escaped === null) {
fail("Unexpected EOF while reading JSON string escape");
}
if (escaped === "u") {
let hex = "";
for (let index = 0; index < 4; index += 1) {
const hexChar = await this.next();
if (hexChar === null) {
fail("Unexpected EOF while reading JSON unicode escape");
}
hex += hexChar;
}
value += String.fromCharCode(Number.parseInt(hex, 16));
continue;
}
value +=
escaped === "b"
? "\b"
: escaped === "f"
? "\f"
: escaped === "n"
? "\n"
: escaped === "r"
? "\r"
: escaped === "t"
? "\t"
: escaped;
}
}
async parseStringArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let index = 0;
while (true) {
const value = await this.readJsonString();
onValue(value, index);
index += 1;
await this.skipWhitespace();
const separator = await this.next();
if (separator === "]") {
return;
}
if (separator !== ",") {
fail(`Expected , or ] but found ${separator ?? "<eof>"}`);
}
await this.skipWhitespace();
}
}
}
function parseHeapFilename(filePath) {
const base = path.basename(filePath);
const match = base.match(
@@ -151,38 +388,89 @@ function resolvePair(options) {
};
}
function loadSummary(filePath) {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
const meta = data.snapshot?.meta;
async function parseSnapshotMeta(scanner) {
await scanner.find('"snapshot":');
await scanner.skipWhitespace();
const metaObjectText = await scanner.readBalancedObject();
const parsed = JSON.parse(metaObjectText);
return parsed?.meta ?? null;
}
async function buildSummary(filePath) {
const scanner = new JsonStreamScanner(filePath);
const meta = await parseSnapshotMeta(scanner);
if (!meta) {
fail(`Invalid heap snapshot: ${filePath}`);
}
const nodeFieldCount = meta.node_fields.length;
const typeNames = meta.node_types[0];
const strings = data.strings;
const typeIndex = meta.node_fields.indexOf("type");
const nameIndex = meta.node_fields.indexOf("name");
const selfSizeIndex = meta.node_fields.indexOf("self_size");
if (typeIndex === -1 || nameIndex === -1 || selfSizeIndex === -1) {
fail(`Unsupported heap snapshot schema: ${filePath}`);
}
const summary = new Map();
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
const type = typeNames[data.nodes[offset + typeIndex]];
const name = strings[data.nodes[offset + nameIndex]];
const selfSize = data.nodes[offset + selfSizeIndex];
const key = `${type}\t${name}`;
const current = summary.get(key) ?? {
type,
name,
const summaryByIndex = new Map();
let nodeCount = 0;
let currentTypeId = 0;
let currentNameId = 0;
let currentSelfSize = 0;
await scanner.find('"nodes":');
await scanner.parseNumberArray((value, index) => {
const fieldIndex = index % nodeFieldCount;
if (fieldIndex === typeIndex) {
currentTypeId = value;
return;
}
if (fieldIndex === nameIndex) {
currentNameId = value;
return;
}
if (fieldIndex === selfSizeIndex) {
currentSelfSize = value;
}
if (fieldIndex !== nodeFieldCount - 1) {
return;
}
const key = `${currentTypeId}\t${currentNameId}`;
const current = summaryByIndex.get(key) ?? {
typeId: currentTypeId,
nameId: currentNameId,
selfSize: 0,
count: 0,
};
current.selfSize += selfSize;
current.selfSize += currentSelfSize;
current.count += 1;
summary.set(key, current);
summaryByIndex.set(key, current);
nodeCount += 1;
});
const requiredNameIds = new Set(
Array.from(summaryByIndex.values(), (entry) => entry.nameId).filter((value) => value >= 0),
);
const nameStrings = new Map();
await scanner.find('"strings":');
await scanner.parseStringArray((value, index) => {
if (requiredNameIds.has(index)) {
nameStrings.set(index, value);
}
});
const summary = new Map();
for (const entry of summaryByIndex.values()) {
const key = `${typeNames[entry.typeId] ?? "unknown"}\t${nameStrings.get(entry.nameId) ?? ""}`;
summary.set(key, {
type: typeNames[entry.typeId] ?? "unknown",
name: nameStrings.get(entry.nameId) ?? "",
selfSize: entry.selfSize,
count: entry.count,
});
}
return {
nodeCount: data.snapshot.node_count,
nodeCount,
summary,
};
}
@@ -205,11 +493,11 @@ function truncate(text, maxLength) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}`;
}
function main() {
async function main() {
const options = parseArgs(process.argv.slice(2));
const pair = resolvePair(options);
const before = loadSummary(pair.before);
const after = loadSummary(pair.after);
const before = await buildSummary(pair.before);
const after = await buildSummary(pair.after);
const minBytes = options.minKb * 1024;
const rows = [];
@@ -262,4 +550,4 @@ function main() {
}
}
main();
await main();

5
.github/labeler.yml vendored
View File

@@ -59,6 +59,11 @@
- any-glob-to-any-file:
- "extensions/nostr/**"
- "docs/channels/nostr.md"
"channel: qqbot":
- changed-files:
- any-glob-to-any-file:
- "extensions/qqbot/**"
- "docs/channels/qqbot.md"
"channel: signal":
- changed-files:
- any-glob-to-any-file:

View File

@@ -66,17 +66,19 @@ jobs:
run: pnpm canvas:a2ui:bundle
- name: Upload A2UI bundle artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
include-hidden-files: true
retention-days: 1
bun-checks:
name: ${{ matrix.check_name }}
needs: [preflight, build-bun-artifacts]
if: needs.preflight.outputs.run_bun_checks == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.bun_checks_matrix) }}

View File

@@ -35,7 +35,6 @@ jobs:
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_release_check: ${{ steps.manifest.outputs.run_release_check }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_matrix: ${{ steps.manifest.outputs.checks_fast_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
@@ -278,40 +277,12 @@ jobs:
include-hidden-files: true
retention-days: 1
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [preflight, build-artifacts]
if: needs.preflight.outputs.run_release_check == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Download dist artifact
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Check release contents
run: pnpm release:check
checks-fast:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_matrix) }}
@@ -331,14 +302,21 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
extensions)
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
export OPENCLAW_TEST_SHARDS="$SHARD_COUNT"
export OPENCLAW_TEST_SHARD_INDEX="$SHARD_INDEX"
fi
pnpm test:extensions
;;
contracts|contracts-protocol)
pnpm build
pnpm test:contracts
pnpm protocol:check
;;
@@ -353,7 +331,7 @@ jobs:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
@@ -432,8 +410,6 @@ jobs:
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
;;
*)
echo "Unsupported checks task: $TASK" >&2
@@ -446,7 +422,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
@@ -489,6 +465,8 @@ jobs:
use-sticky-disk: "false"
- name: Check types and lint and oxfmt
env:
OPENCLAW_LOCAL_CHECK: "0"
run: pnpm check
- name: Strict TS build smoke
@@ -518,6 +496,51 @@ jobs:
continue-on-error: true
run: pnpm run lint:plugins:no-extension-imports
- name: Run no-random-messaging guard
id: no_random_messaging
continue-on-error: true
run: pnpm run lint:tmp:no-random-messaging
- name: Run channel-agnostic boundary guard
id: channel_agnostic_boundaries
continue-on-error: true
run: pnpm run lint:tmp:channel-agnostic-boundaries
- name: Run no-raw-channel-fetch guard
id: no_raw_channel_fetch
continue-on-error: true
run: pnpm run lint:tmp:no-raw-channel-fetch
- name: Run ingress owner guard
id: ingress_owner
continue-on-error: true
run: pnpm run lint:agent:ingress-owner
- name: Run no-register-http-handler guard
id: no_register_http_handler
continue-on-error: true
run: pnpm run lint:plugins:no-register-http-handler
- name: Run no-monolithic plugin-sdk entry import guard
id: no_monolithic_plugin_sdk_entry_imports
continue-on-error: true
run: pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
- name: Run no-extension-src-imports guard
id: no_extension_src_imports
continue-on-error: true
run: pnpm run lint:plugins:no-extension-src-imports
- name: Run no-extension-test-core-imports guard
id: no_extension_test_core_imports
continue-on-error: true
run: pnpm run lint:plugins:no-extension-test-core-imports
- name: Run plugin-sdk subpaths exported guard
id: plugin_sdk_subpaths_exported
continue-on-error: true
run: pnpm run lint:plugins:plugin-sdk-subpaths-exported
- name: Run web search provider boundary guard
id: web_search_provider_boundary
continue-on-error: true
@@ -533,6 +556,11 @@ jobs:
continue-on-error: true
run: pnpm run lint:extensions:no-plugin-sdk-internal
- name: Run extension relative-outside-package guard
id: extension_relative_outside_package_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-relative-outside-package
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
@@ -543,16 +571,6 @@ jobs:
continue-on-error: true
run: pnpm test:gateway:watch-regression
- name: Check config docs drift statefile
id: config_docs_drift
continue-on-error: true
run: pnpm config:docs:check
- name: Check plugin SDK API baseline drift
id: plugin_sdk_api_drift
continue-on-error: true
run: pnpm plugin-sdk:api:check
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
@@ -565,24 +583,40 @@ jobs:
if: always()
env:
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
NO_RANDOM_MESSAGING_OUTCOME: ${{ steps.no_random_messaging.outcome }}
CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME: ${{ steps.channel_agnostic_boundaries.outcome }}
NO_RAW_CHANNEL_FETCH_OUTCOME: ${{ steps.no_raw_channel_fetch.outcome }}
INGRESS_OWNER_OUTCOME: ${{ steps.ingress_owner.outcome }}
NO_REGISTER_HTTP_HANDLER_OUTCOME: ${{ steps.no_register_http_handler.outcome }}
NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME: ${{ steps.no_monolithic_plugin_sdk_entry_imports.outcome }}
NO_EXTENSION_SRC_IMPORTS_OUTCOME: ${{ steps.no_extension_src_imports.outcome }}
NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
CONFIG_DOCS_DRIFT_OUTCOME: ${{ steps.config_docs_drift.outcome }}
PLUGIN_SDK_API_DRIFT_OUTCOME: ${{ steps.plugin_sdk_api_drift.outcome }}
run: |
failures=0
for result in \
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
"lint:tmp:no-random-messaging|$NO_RANDOM_MESSAGING_OUTCOME" \
"lint:tmp:channel-agnostic-boundaries|$CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME" \
"lint:tmp:no-raw-channel-fetch|$NO_RAW_CHANNEL_FETCH_OUTCOME" \
"lint:agent:ingress-owner|$INGRESS_OWNER_OUTCOME" \
"lint:plugins:no-register-http-handler|$NO_REGISTER_HTTP_HANDLER_OUTCOME" \
"lint:plugins:no-monolithic-plugin-sdk-entry-imports|$NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME" \
"lint:plugins:no-extension-src-imports|$NO_EXTENSION_SRC_IMPORTS_OUTCOME" \
"lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
"lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
"config-docs-drift|$CONFIG_DOCS_DRIFT_OUTCOME" \
"plugin-sdk-api-drift|$PLUGIN_SDK_API_DRIFT_OUTCOME"; do
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"
if [ "$outcome" != "success" ]; then
@@ -690,7 +724,7 @@ jobs:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 20
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the 32 vCPU runner.

View File

@@ -58,6 +58,12 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Build
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}

View File

@@ -52,19 +52,6 @@ jobs:
install-bun: "false"
use-sticky-disk: "false"
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
@@ -84,11 +71,29 @@ jobs:
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
env:
OPENCLAW_LOCAL_CHECK: "0"
run: pnpm check
- name: Build
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Verify release contents
run: pnpm release:check
@@ -142,6 +147,24 @@ jobs:
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Build
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
@@ -155,17 +178,5 @@ jobs:
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Publish
run: bash scripts/openclaw-npm-publish.sh --publish

View File

@@ -60,8 +60,11 @@ jobs:
ACTIONLINT_VERSION="1.7.11"
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
curl -sSfL -o "${archive}" "${base_url}/${archive}"
curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
# GitHub release downloads occasionally return transient 5xx responses.
# Retry all curl errors here so workflow-sanity does not fail closed on
# a one-off release edge outage.
curl --retry 5 --retry-delay 2 --retry-all-errors -sSfL -o "${archive}" "${base_url}/${archive}"
curl --retry 5 --retry-delay 2 --retry-all-errors -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
grep " ${archive}\$" checksums.txt | sha256sum -c -
tar -xzf "${archive}" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint

4
.gitignore vendored
View File

@@ -85,6 +85,7 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
docs/internal/
tmp/
IDENTITY.md
USER.md
@@ -137,3 +138,6 @@ docs/superpowers
# Deprecated changelog fragment workflow
changelog/fragments/
# Local scratch workspace
.tmp/

View File

@@ -1,2 +1,3 @@
**/node_modules/
**/.runtime-deps-*/
docs/.generated/

View File

@@ -1,7 +1,7 @@
# Repository Guidelines
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Project Structure & Module Organization
@@ -9,17 +9,59 @@
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename.
- Bundled plugin naming: for repo-owned workspace plugins, keep the canonical plugin id aligned across `openclaw.plugin.json:id`, `extensions/<id>` by default, and package names anchored to the same id (`@openclaw/<id>` or approved suffix forms like `-provider`, `-plugin`, `-speech`, `-sandbox`, `-media-understanding`). Keep `openclaw.install.npmSpec` equal to the package name and `openclaw.channel.id` equal to the plugin id when present. Exceptions must be explicit and covered by the repo invariant test.
- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
- Bundled plugin naming: for repo-owned workspace plugins, keep the canonical plugin id aligned across `openclaw.plugin.json:id`, the default workspace folder name, and package names anchored to the same id (`@openclaw/<id>` or approved suffix forms like `-provider`, `-plugin`, `-speech`, `-sandbox`, `-media-understanding`). Keep `openclaw.install.npmSpec` equal to the package name and `openclaw.channel.id` equal to the plugin id when present. Exceptions must be explicit and covered by the repo invariant test.
- Plugins: live in the bundled workspace plugin tree (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
## Architecture Boundaries
- Start here for the repo map:
- bundled workspace plugin tree = bundled plugins and the closest example surface for third-party plugins
- `src/plugin-sdk/*` = the public plugin contract that extensions are allowed to import
- `src/channels/*` = core channel implementation details behind the plugin/channel boundary
- `src/plugins/*` = plugin discovery, manifest validation, loader, registry, and contract enforcement
- `src/gateway/protocol/*` = typed Gateway control-plane and node wire protocol
- Progressive disclosure lives in local boundary guides:
- bundled-plugin-tree `AGENTS.md`
- `src/plugin-sdk/AGENTS.md`
- `src/channels/AGENTS.md`
- `src/plugins/AGENTS.md`
- `src/gateway/protocol/AGENTS.md`
- Plugin and extension boundary:
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
- Rule: core code and tests must not deep-import bundled plugin internals such as a plugin's `src/**` files or `onboard.js`. If core needs a bundled plugin helper, expose it through that plugin's `api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
- Channel boundary:
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
- Definition files: `src/channels/plugins/types.plugin.ts`, `src/channels/plugins/types.core.ts`, `src/channels/plugins/types.adapters.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/channel-contract.ts`
- Rule: `src/channels/**` is core implementation. If plugin authors need a new seam, add it to the Plugin SDK instead of telling them to import channel internals.
- Provider/model boundary:
- Public docs: `docs/plugins/sdk-provider-plugins.md`, `docs/concepts/model-providers.md`, `docs/plugins/architecture.md`
- Definition files: `src/plugins/types.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/provider-auth.ts`, `src/plugin-sdk/provider-catalog-shared.ts`, `src/plugin-sdk/provider-model-shared.ts`
- Rule: core owns the generic inference loop; provider plugins own provider-specific behavior through registration and typed hooks. Do not solve provider needs by reaching into unrelated core internals.
- Rule: avoid ad hoc reads of `plugins.entries.<id>.config` from unrelated core code. If core needs plugin-owned auth/config behavior, add or use a generic seam (`resolveSyntheticAuth`, public SDK/helper facades, manifest metadata, plugin auto-enable hooks) and honor plugin disablement plus SecretRef semantics.
- Rule: vendor-owned tools and settings belong in the owning plugin. Do not add provider-specific tool config, secret collection, or runtime enablement to core `tools.*` surfaces unless the tool is intentionally core-owned.
- Gateway protocol boundary:
- Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md`
- Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts`
- Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or the plugin's `api.ts`, not private extension modules.
## Docs Linking (Mintlify)
@@ -60,7 +102,8 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
@@ -69,18 +112,31 @@
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to lower-memory `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
- "gate" means a verification command or command set that must be green for the decision you are making.
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
- A landing gate is the broader bar before pushing `main`, usually `pnpm check`, `pnpm test`, and `pnpm build` when the touched surface can affect build output, packaging, lazy-loading/module boundaries, or published surfaces.
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline artifacts live together under `docs/.generated/`.
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
- Plugin SDK API drift uses `pnpm plugin-sdk:api:gen` / `pnpm plugin-sdk:api:check`.
- If you change config schema/help or the public Plugin SDK surface, update the matching baseline artifact and keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
- Preferred landing bar for pushes to `main`: `pnpm check` and `pnpm test`, with a green result when feasible.
- Verification modes for work on `main`:
- Default mode: `main` is relatively stable. Count pre-commit hook coverage when it already verified the current tree, avoid rerunning the exact same checks just for ceremony, and prefer keeping CI/main green before landing.
- Fast-commit mode: `main` is moving fast and you intentionally optimize for shorter commit loops. Prefer explicit local verification close to the final landing point, and it is acceptable to use `--no-verify` for intermediate or catch-up commits after equivalent checks have already run locally.
- Preferred landing bar for pushes to `main`: in Default mode, favor `pnpm check` and `pnpm test` near the final rebase/push point when feasible. In fast-commit mode, verify the touched surface locally near landing without insisting every intermediate commit replay the full hook.
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface.
- Default rule: do not land changes with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. Fast-commit mode changes how verification is sequenced; it does not lower the requirement to validate and clean up the touched surface before final landing.
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
- Do not use scoped tests as permission to ignore plausibly related failures.
@@ -88,11 +144,19 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt.
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- Never add `@ts-nocheck` and do not add inline lint suppressions by default. Fix root causes first; only keep a suppression when the code is intentionally correct, the rule cannot express that safely, and the comment explains why.
- Do not disable `no-explicit-any`; prefer real types, `unknown`, or a narrow adapter/helper instead. Update Oxlint/Oxfmt config only when required.
- Prefer `zod` or existing schema helpers at external boundaries such as config, webhook payloads, CLI/JSON output, persisted JSON, and third-party API responses.
- Prefer discriminated unions when parameter shape changes runtime behavior.
- Prefer `Result<T, E>`-style outcomes and closed error-code unions for recoverable runtime decisions.
- Keep human-readable strings for logs, CLI output, and UI; do not use freeform strings as the source of truth for internal branching.
- Avoid `?? 0`, empty-string, empty-object, or magic-string sentinels when they can change runtime meaning silently.
- If introducing a new optional field or nullable semantic in core logic, prefer an explicit union or dedicated type when the value changes behavior.
- New runtime control-flow code should not branch on `error: string` or `reason: string` when a closed code union would be reasonable.
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
- Extension package boundary guardrail: inside a bundled plugin package, do not use relative imports/exports that resolve outside that same package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
@@ -116,12 +180,17 @@
- When tests need example Anthropic/OpenAI model constants, prefer `sonnet-4.6` and `gpt-5.4`; update older Anthropic/GPT examples when you touch those tests.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Write tests to clean up timers, env, globals, mocks, sockets, temp dirs, and module state so `--isolate=false` stays green.
- Test performance guardrail: do not put `vi.resetModules()` plus `await import(...)` in `beforeEach`/per-test loops for heavy modules unless module state truly requires it. Prefer static imports or one-time `beforeAll` imports, then reset mocks/runtime state directly.
- Test performance guardrail: inside an extension package, prefer a thin local seam (`./api.ts`, `./runtime-api.ts`, or a narrower local `*.runtime-api.ts`) over direct `openclaw/plugin-sdk/*` imports for internal production code. Keep local seams curated and lightweight; only reach for direct `plugin-sdk/*` imports when you are crossing a real package boundary or when no suitable local seam exists yet.
- Test performance guardrail: keep expensive runtime fallback work such as snapshotting, migration, installs, or bootstrap behind dedicated `*.runtime.ts` boundaries so tests can mock the seam instead of accidentally invoking real work.
- Test performance guardrail: for import-only/runtime-wrapper tests, keep the wrapper lazy. Do not eagerly load heavy verification/bootstrap/runtime modules at module top level if the exported function can import them on demand.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already.
- Keep Vitest on `forks` only. Do not introduce or reintroduce any non-`forks` Vitest pool or alternate execution mode in configs, wrapper scripts, or default test commands without explicit approval in this chat. This includes `threads`, `vmThreads`, `vmForks`, and any future/nonstandard pool variant.
- If local Vitest runs cause memory pressure, the wrapper now derives budgets from host capabilities (CPU, memory band, current load). For a conservative explicit override during land/gate runs, use `OPENCLAW_TEST_PROFILE=serial OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`.
- Live tests (real keys): `OPENCLAW_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- `pnpm test:live` defaults quiet now. Keep `[live]` progress; suppress profile/gateway chatter. Full logs: `OPENCLAW_LIVE_TEST_QUIET=0 pnpm test:live`.
- Full kit + whats covered: `docs/help/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
- Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section.
@@ -196,6 +265,7 @@
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** prefer grouped `commit` / `pull --rebase` / `push` cycles for related work instead of many tiny syncs.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.

File diff suppressed because it is too large Load Diff

View File

@@ -94,6 +94,7 @@ Welcome to the lobster tank! 🦞
- `pnpm test:extension --list` to see valid extension ids
- If you changed shared plugin or channel surfaces, run `pnpm test:contracts`
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
- These commands also cover the shared seam/smoke files that the default unit lane skips
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.

View File

@@ -5,15 +5,16 @@
#
# Multi-stage build produces a minimal runtime image without build tools,
# source code, or Bun. Works with Docker, Buildx, and Podman.
# The ext-deps stage extracts only the package.json files we need from
# extensions/, so the main build layer is not invalidated by unrelated
# extension source changes.
# The ext-deps stage extracts only the package.json files we need from the
# bundled plugin workspace tree, so the main build layer is not invalidated by
# unrelated plugin source changes.
#
# Two runtime variants:
# Default (bookworm): docker build .
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_VARIANT=default
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
ARG OPENCLAW_DOCKER_APT_UPGRADE=1
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
@@ -27,18 +28,20 @@ ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f3411
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
ARG OPENCLAW_EXTENSIONS
COPY extensions /tmp/extensions
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
COPY ${OPENCLAW_BUNDLED_PLUGIN_DIR} /tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Copy package.json for opted-in extensions so pnpm resolves their deps.
RUN mkdir -p /out && \
for ext in $OPENCLAW_EXTENSIONS; do \
if [ -f "/tmp/extensions/$ext/package.json" ]; then \
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
mkdir -p "/out/$ext" && \
cp "/tmp/extensions/$ext/package.json" "/out/$ext/package.json"; \
cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/$ext/package.json"; \
fi; \
done
# ── Stage 2: Build ──────────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Install Bun (required for build scripts). Retry the whole bootstrap flow to
# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds.
@@ -61,8 +64,9 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs ./scripts/postinstall-bundled-plugins.mjs
COPY --from=ext-deps /out/ ./extensions/
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
@@ -73,7 +77,7 @@ COPY . .
# Normalize extension paths now so runtime COPY preserves safe modes
# without adding a second full extensions layer.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
RUN for dir in /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
@@ -114,6 +118,7 @@ LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-sli
# ── Stage 3: Runtime ────────────────────────────────────────────
FROM base-${OPENCLAW_VARIANT}
ARG OPENCLAW_VARIANT
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
ARG OPENCLAW_DOCKER_APT_UPGRADE
# OCI base-image metadata for downstream image consumers.
@@ -148,13 +153,13 @@ COPY --from=runtime-assets --chown=node:node /app/dist ./dist
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
COPY --from=runtime-assets --chown=node:node /app/package.json .
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a

View File

@@ -14,6 +14,7 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
chromium \
curl \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
git \
jq \

4
Makefile Normal file
View File

@@ -0,0 +1,4 @@
.PHONY: build
build:
pnpm build

View File

@@ -57,7 +57,10 @@ These are frequently reported but are typically closed with no code change:
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics.
- Reports that treat `POST /tools/invoke` under shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) as a narrower per-request/per-scope authorization surface. That endpoint is designed as the same trusted-operator HTTP boundary: shared-secret bearer auth is full operator access there, narrower `x-openclaw-scopes` values do not reduce that path, and owner-only tool policy follows the shared-secret operator contract.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- Reports that only show an ACP tool can indirectly execute, mutate, orchestrate sessions, or reach another tool/runtime without demonstrating bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. ACP silent approval is intentionally limited to narrow readonly classes; parity-only indirect-command findings are hardening, not vulnerabilities.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
@@ -93,7 +96,14 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) and direct tool endpoint (`POST /tools/invoke`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- Concretely, on the OpenAI-compatible HTTP surface:
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret
- those requests receive the full default operator scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`)
- chat-turn endpoints (`/v1/chat/completions`, `/v1/responses`) also treat those shared-secret callers as owner senders for owner-only tool policy
- `POST /tools/invoke` follows that same shared-secret rule and also treats those callers as owner senders for owner-only tool policy
- narrower `x-openclaw-scopes` headers are ignored for that shared-secret path
- only identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on private ingress) honor declared per-request operator scopes
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
@@ -101,7 +111,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host.
- `tools.exec.host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
@@ -129,6 +139,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Reports whose only claim is that an ACP-exposed tool can indirectly execute commands, mutate host state, or reach another privileged tool/runtime without demonstrating a bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. These are hardening-only findings, not vulnerabilities.
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.

View File

@@ -2,6 +2,147 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.28</title>
<pubDate>Sun, 29 Mar 2026 02:10:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032890</sparkle:version>
<sparkle:shortVersionString>2026.3.28</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.28</h2>
<h3>Breaking</h3>
<ul>
<li>Providers/Qwen: remove the deprecated <code>qwen-portal-auth</code> OAuth integration for <code>portal.qwen.ai</code>; migrate to Model Studio with <code>openclaw onboard --auth-choice modelstudio-api-key</code>. (#52709) Thanks @pomelo-nwu.</li>
<li>Config/Doctor: drop automatic config migrations older than two months; very old legacy keys now fail validation instead of being rewritten on load or by <code>openclaw doctor</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>xAI/tools: move the bundled xAI provider to the Responses API, add first-class <code>x_search</code>, and auto-enable the xAI plugin from owned web-search and tool config so bundled Grok auth/configured search flows work without manual plugin toggles. (#56048) Thanks @huntharo.</li>
<li>xAI/onboarding: let the bundled Grok web-search plugin offer optional <code>x_search</code> setup during <code>openclaw onboard</code> and <code>openclaw configure --section web</code>, including an x_search model picker with the shared xAI key.</li>
<li>MiniMax: add image generation provider for <code>image-01</code> model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.</li>
<li>Plugins/hooks: add async <code>requireApproval</code> to <code>before_tool_call</code> hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the <code>/approve</code> command on any channel. The <code>/approve</code> command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.</li>
<li>ACP/channels: add current-conversation ACP binds for Discord, BlueBubbles, and iMessage so <code>/acp spawn codex --bind here</code> can turn the current chat into a Codex-backed workspace without creating a child thread, and document the distinction between chat surface, ACP session, and runtime workspace.</li>
<li>OpenAI/apply_patch: enable <code>apply_patch</code> by default for OpenAI and OpenAI Codex models, and align its sandbox policy access with <code>write</code> permissions.</li>
<li>Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace <code>gateway run --claude-cli-logs</code> with generic <code>--cli-backend-logs</code> while keeping the old flag as a compatibility alias.</li>
<li>Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual <code>plugins.allow</code> entries.</li>
<li>Podman: simplify the container setup around the current rootless user, install the launch helper under <code>~/.local/bin</code>, and document the host-CLI <code>openclaw --container <name> ...</code> workflow instead of a dedicated <code>openclaw</code> service user.</li>
<li>Slack/tool actions: add an explicit <code>upload-file</code> Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs.</li>
<li>Message actions/files: start unifying file-first sends on the canonical <code>upload-file</code> action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through <code>upload-file</code> while keeping the legacy <code>sendAttachment</code> alias.</li>
<li>Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.</li>
<li>CLI: add <code>openclaw config schema</code> to print the generated JSON schema for <code>openclaw.json</code>. (#54523) Thanks @kvokka.</li>
<li>Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled <code>tts.<provider></code> API-key shapes.</li>
<li>Memory/plugins: move the pre-compaction memory flush plan behind the active memory plugin contract so <code>memory-core</code> owns flush prompts and target-path policy instead of hardcoded core logic.</li>
<li>MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.</li>
<li>Plugins/runtime: expose <code>runHeartbeatOnce</code> in the plugin runtime <code>system</code> namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. <code>heartbeat: { target: "last" }</code>). (#40299) Thanks @loveyana.</li>
<li>Agents/compaction: preserve the post-compaction AGENTS refresh on stale-usage preflight compaction for both immediate replies and queued followups. (#49479) Thanks @jared596.</li>
<li>Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual <code>/compact</code> no-op cases as skipped instead of failed. (#51072) Thanks @afurm.</li>
<li>Docs: add <code>pnpm docs:check-links:anchors</code> for Mintlify anchor validation while keeping <code>scripts/docs-link-audit.mjs</code> as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.</li>
<li>Tavily: mark outbound API requests with <code>X-Client-Source: openclaw</code> so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/Anthropic: recover unhandled provider stop reasons (e.g. <code>sensitive</code>) as structured assistant errors instead of crashing the agent run. (#56639)</li>
<li>Google/models: resolve Gemini 3.1 pro, flash, and flash-lite for all Google provider aliases by passing the actual runtime provider ID and adding a template-provider fallback; fix flash-lite prefix ordering. (#56567)</li>
<li>OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing <code>instructions</code>. (#54829) Thanks @neeravmakwana.</li>
<li>Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like <code>openrouter</code> and <code>minimax-portal</code>. (#54858) Thanks @MonkeyLeeT.</li>
<li>WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth</li>
<li>Telegram/splitting: replace proportional text estimate with verified HTML-length search so long messages split at word boundaries instead of mid-word; gracefully degrade when tag overhead exceeds the limit. (#56595)</li>
<li>Telegram/delivery: skip whitespace-only and hook-blanked text replies in bot delivery to prevent GrammyError 400 empty-text crashes. (#56620)</li>
<li>Telegram/send: validate <code>replyToMessageId</code> at all four API sinks with a shared normalizer that rejects non-numeric, NaN, and mixed-content strings. (#56587)</li>
<li>Mistral: normalize OpenAI-compatible request flags so official Mistral API runs no longer fail with remaining <code>422 status code (no body)</code> chat errors.</li>
<li>Control UI/config: keep sensitive raw config hidden by default, replace the blank blocked editor with an explicit reveal-to-edit state, and restore raw JSON editing without auto-exposing secrets. Fixes #55322.</li>
<li>CLI/zsh: defer <code>compdef</code> registration until <code>compinit</code> is available so zsh completion loads cleanly with plugin managers and manual setups. (#56555)</li>
<li>BlueBubbles/debounce: guard debounce flush against null message text by sanitizing at the enqueue boundary and adding an independent combiner guard. (#56573)</li>
<li>Auto-reply: suppress JSON-wrapped <code>{"action":"NO_REPLY"}</code> control envelopes before channel delivery with a strict single-key detector; preserves media when text is only a silent envelope. (#56612)</li>
<li>ACP/ACPX agent registry: align OpenClaw's ACPX built-in agent mirror with the latest <code>openclaw/acpx</code> command defaults and built-in aliases, pin versioned <code>npx</code> built-ins to exact versions, and stop unknown ACP agent ids from falling through to raw <code>--agent</code> command execution on the MCP-proxy path. (#28321) Thanks @m0nkmaster and @vincentkoc.</li>
<li>Security/audit: extend web search key audit to recognize Gemini, Grok/xAI, Kimi, Moonshot, and OpenRouter credentials via a boundary-safe bundled-web-search registry shim. (#56540)</li>
<li>Docs/FAQ: remove broken Xfinity SSL troubleshooting cross-links from English and zh-CN FAQ entries — both sections already contain the full workaround inline. (#56500)</li>
<li>Telegram: deliver verbose tool summaries inside forum topic sessions again, so threaded topic chats now match DM verbose behavior. (#43236) Thanks @frankbuild.</li>
<li>BlueBubbles/CLI agents: restore inbound prompt image refs for CLI routed turns, reapply embedded runner image size guardrails, and cover both CLI image transport paths with regression tests. (#51373)</li>
<li>BlueBubbles/groups: optionally enrich unnamed participant lists with local macOS Contacts names after group gating passes, so group member context can show names instead of only raw phone numbers.</li>
<li>Discord/reconnect: drain stale gateway sockets, clear cached resume state before forced fresh reconnects, and fail closed when old sockets refuse to die so Discord recovery stops looping on poisoned resume state. (#54697) Thanks @ngutman.</li>
<li>iMessage: stop leaking inline <code>[[reply_to:...]]</code> tags into delivered text by sending <code>reply_to</code> as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn.</li>
<li>CLI/plugins: make routed commands use the same auto-enabled bundled-channel snapshot as gateway startup, so configured bundled channels like Slack load without requiring a prior config rewrite. (#54809) Thanks @neeravmakwana.</li>
<li>CLI/message send: write manual <code>openclaw message send</code> deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617.</li>
<li>CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider</li>
<li>Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so <code>/status</code> shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.</li>
<li>OpenAI/WebSocket: preserve reasoning replay metadata and tool-call item ids on WebSocket tool turns, and start a fresh response chain when full-context resend is required. (#53856) Thanks @xujingchen1996.</li>
<li>OpenAI/WS: restore reasoning blocks for Responses WebSocket runs and keep reasoning/tool-call replay metadata intact so resumed sessions do not lose or break follow-up reasoning-capable turns. (#53856) Thanks @xujingchen1996.</li>
<li>Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.</li>
<li>Claude CLI: switch the bundled Claude CLI backend to <code>stream-json</code> output so watchdogs see progress on long runs, and keep session/usage metadata even when Claude finishes with an empty result line. (#49698) Thanks @felear2022.</li>
<li>Claude CLI/MCP: always pass a strict generated <code>--mcp-config</code> overlay for background Claude CLI runs, including the empty-server case, so Claude does not inherit ambient user/global MCP servers. (#54961) Thanks @markojak.</li>
<li>Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy <code>mediaUrl</code>. (#50930) Thanks @infichen.</li>
<li>Chat/UI: move the chat send button onto the shared ghost-button theme styling, while keeping the stop button icon readable on the danger state. (#55075) Thanks @bottenbenny.</li>
<li>WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading <code><E.164|group JID></code> format hint. Thanks @mcaxtr.</li>
<li>Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.</li>
<li>Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.</li>
<li>Telegram/pairing: ignore self-authored DM <code>message</code> updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo</li>
<li>Mattermost/replies: keep pairing replies, slash-command fallback replies, and model-picker messages on the resolved config path so <code>exec:</code> SecretRef bot tokens work across all outbound reply branches. (#48347) thanks @mathiasnagler.</li>
<li>Microsoft Teams/config: accept the existing <code>welcomeCard</code>, <code>groupWelcomeCard</code>, <code>promptStarters</code>, and feedback/reflection keys in strict config validation so already-supported Teams runtime settings stop failing schema checks. (#54679) Thanks @gumclaw.</li>
<li>MCP/channels: add a Gateway-backed channel MCP bridge with Codex/Claude-facing conversation tools, Claude channel notifications, and safer stdio bridge lifecycle handling for reconnects and routed session discovery.</li>
<li>Plugins/SDK: thread <code>moduleUrl</code> through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory correctly resolve <code>openclaw/plugin-sdk/*</code> subpath imports, and gate <code>plugin-sdk:check-exports</code> in <code>release:check</code>. (#54283) Thanks @xieyongliang.</li>
<li>Config/web fetch: allow the documented <code>tools.web.fetch.maxResponseBytes</code> setting in runtime schema validation so valid configs no longer fail with unrecognized-key errors. (#53401) Thanks @erhhung.</li>
<li>Message tool/buttons: keep the shared <code>buttons</code> schema optional in merged tool definitions so plain <code>action=send</code> calls stop failing validation when no buttons are provided. (#54418) Thanks @adzendo.</li>
<li>Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate <code>tool_call_id</code> values with HTTP 400. (#40996) Thanks @xaeon2026.</li>
<li>Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition <code>strict</code> fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.</li>
<li>Plugins/context engines: retry strict legacy <code>assemble()</code> calls without the new <code>prompt</code> field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.</li>
<li>CLI/update status: explicitly say <code>up to date</code> when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.</li>
<li>Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.</li>
<li>Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.</li>
<li>Feishu: use the original message <code>create_time</code> instead of <code>Date.now()</code> for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.</li>
<li>Control UI/Skills: open skill detail dialogs with the browser modal lifecycle so clicking a skill row keeps the panel centered instead of rendering it off-screen at the bottom of the page.</li>
<li>Matrix/replies: include quoted poll question/options in inbound reply context so the agent sees the original poll content when users reply to Matrix poll messages. (#55056) Thanks @alberthild.</li>
<li>Matrix/plugins: keep plugin bootstrap from crashing when built runtime mixes bare and deep <code>matrix-js-sdk</code> entrypoints, so unrelated channels do not get taken down during plugin load. (#56273) Thanks @aquaright1.</li>
<li>Agents/sandbox: honor <code>tools.sandbox.tools.alsoAllow</code>, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.</li>
<li>Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman.</li>
<li>Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug.</li>
<li>Agents/compaction: reconcile <code>sessions.json.compactionCount</code> after a late embedded auto-compaction success so persisted session counts catch up once the handler reports completion. (#45493) Thanks @jackal092927.</li>
<li>Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet.</li>
<li>Plugins/runtime: reuse only compatible active plugin registries across tools, providers, web search, and channel bootstrap, align <code>/tools/invoke</code> plugin loading with the session workspace, and retry outbound channel recovery when the pinned channel surface changes so plugin tools and channels stop disappearing or re-registering from mismatched runtime loads. Thanks @gumadeiras.</li>
<li>Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.</li>
<li>Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.</li>
<li>Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth.</li>
<li>Discord/gateway shutdown: suppress reconnect-exhausted events that were already buffered before teardown flips <code>lifecycleStopping</code>, so stale-socket Discord restarts no longer crash the whole gateway. Fixes #55403 and #55421. Thanks @lml2468 and @vincentkoc.</li>
<li>GitHub Copilot/auth refresh: treat large <code>expires_at</code> values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a <code>setTimeout</code> overflow hot loop. (#55360) Thanks @michael-abdo.</li>
<li>Agents/status: use the persisted runtime session model in <code>session_status</code> when no explicit override exists, and honor per-agent <code>thinkingDefault</code> in both <code>session_status</code> and <code>/status</code>. (#55425) Thanks @scoootscooob, @xaeon2026, and @ysfbsf.</li>
<li>Heartbeat/runner: guarantee the interval timer is re-armed after heartbeat runs and unexpected runner errors so scheduled heartbeats do not silently stop after an interrupted cycle. (#52270) Thanks @MiloStack.</li>
<li>Config/Doctor: rewrite stale bundled plugin load paths from legacy bundled-plugin locations to the packaged bundled path, including directory-name mismatches and slash-suffixed config entries. (#55054) Thanks @SnowSky1.</li>
<li>WhatsApp/mentions: stop treating mentions embedded in quoted messages as direct mentions so replying to a message that @mentioned the bot no longer falsely triggers mention gating. (#52711) Thanks @lurebat.</li>
<li>Matrix: keep separate 2-person rooms out of DM routing after <code>m.direct</code> seeds successfully, while still honoring explicit <code>is_direct</code> state and startup fallback recovery. (#54890) thanks @private-peter</li>
<li>Agents/ollama fallback: surface non-2xx Ollama HTTP errors with a leading status code so HTTP 503 responses trigger model fallback again. (#55214) Thanks @bugkill3r.</li>
<li>Feishu/tools: stop synthetic agent ids like <code>agent-spawner</code> from being treated as Feishu account ids during tool execution, so tools fall back to the configured/default Feishu account unless the contextual id is a real enabled Feishu account. (#55627) Thanks @MonkeyLeeT.</li>
<li>Google/tools: strip empty <code>required: []</code> arrays from Gemini tool schemas so optional-only tool parameters no longer trigger Google validator 400s. (#52106) Thanks @oliviareid-svg.</li>
<li>Onboarding/TUI/local gateways: show the resolved gateway port in setup output, clarify no-daemon local health/dashboard messaging, and preserve loopback Control UI auth on reruns and explicit local gateway URLs so local quickstart flows recover cleanly. (#55730) Thanks @shakkernerd.</li>
<li>TUI/chat log: keep system messages as single logical entries and prune overflow at whole-message boundaries so wrapped system spacing stays intact. (#55732) Thanks @shakkernerd.</li>
<li>TUI/activation: validate <code>/activation</code> arguments in the TUI and reject invalid values instead of silently coercing them to <code>mention</code>. (#55733) Thanks @shakkernerd.</li>
<li>Agents/model switching: apply <code>/model</code> changes to active embedded runs at the next safe retry boundary, so overloaded or retrying turns switch to the newly selected model instead of staying pinned to the old provider.</li>
<li>Agents/Codex fallback: classify Codex <code>server_error</code> payloads as failoverable, sanitize <code>Codex error:</code> payloads before they reach chat, preserve context-overflow guidance for prefixed <code>invalid_request_error</code> payloads, and omit provider <code>request_id</code> values from user-facing UI copy. (#42892) Thanks @xaeon2026.</li>
<li>Memory/search: share memory embedding provider registrations across split plugin runtimes so memory search no longer fails with unknown provider errors after memory-core registers built-in adapters. (#55945) Thanks @glitch418x.</li>
<li>Discord/Carbon beta: update <code>@buape/carbon</code> to the latest beta and pass the new <code>RateLimitError</code> request argument so Discord stays compatible with the upstream beta constructor change. (#55980) Thanks @ngutman.</li>
<li>Plugins/inbound claims: pass full inbound attachment arrays through <code>inbound_claim</code> hook metadata while keeping the legacy singular media attachment fields for compatibility. (#55452) Thanks @huntharo.</li>
<li>Plugins/Matrix: preserve sender filenames for inbound media by forwarding <code>originalFilename</code> to <code>saveMediaBuffer</code>. (#55692) thanks @esrehmki.</li>
<li>Matrix/mentions: recognize <code>matrix.to</code> mentions whose visible label uses the bot's room display name, so <code>requireMention: true</code> rooms respond correctly in modern Matrix clients. (#55393) thanks @nickludlam.</li>
<li>Ollama/thinking off: route <code>thinkingLevel=off</code> through the live Ollama extension request path so thinking-capable Ollama models now receive top-level <code>think: false</code> instead of silently generating hidden reasoning tokens. (#53200) Thanks @BruceMacD.</li>
<li>Plugins/diffs: stage bundled <code>@pierre/diffs</code> runtime dependencies during packaged updates so the bundled diff viewer keeps loading after global installs and updates. (#56077) Thanks @gumadeiras.</li>
<li>Plugins/diffs: load bundled Pierre themes without JSON module imports so diff rendering keeps working on newer Node builds. (#45869) thanks @NickHood1984.</li>
<li>Plugins/uninstall: remove owned <code>channels.<id></code> config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000.</li>
<li>Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras</li>
<li>Plugins/Matrix: resolve env-backed <code>accessToken</code> and <code>password</code> SecretRefs against the active Matrix config env path during startup, and officially accept SecretRef <code>accessToken</code> config values. (#54980) thanks @kakahu2015.</li>
<li>Microsoft Teams/proactive DMs: prefer the freshest personal conversation reference for <code>user:<aadObjectId></code> sends when multiple stored references exist, so replies stop targeting stale DM threads. (#54702) Thanks @gumclaw.</li>
<li>Gateway/plugins: reuse the session workspace when building HTTP <code>/tools/invoke</code> tool lists and harden tool construction to infer the session agent workspace by default, so workspace plugins do not re-register on repeated HTTP tool calls. (#56101) thanks @neeravmakwana</li>
<li>Brave/web search: normalize unsupported Brave <code>country</code> filters to <code>ALL</code> before request and cache-key generation so locale-derived values like <code>VN</code> stop failing with upstream 422 validation errors. (#55695) Thanks @chen-zhang-cs-code.</li>
<li>Discord/replies: preserve leading indentation when stripping inline reply tags so reply-tagged plain text and fenced code blocks keep their formatting. (#55960) Thanks @Nanako0129.</li>
<li>Daemon/status: surface immediate gateway close reasons from lightweight probes and prefer those concrete auth or pairing failures over generic timeouts in <code>openclaw daemon status</code>. (#56282) Thanks @mbelinky.</li>
<li>Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.</li>
<li>Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)</li>
<li>Plugins/Matrix: load bundled <code>@matrix-org/matrix-sdk-crypto-nodejs</code> through <code>createRequire(...)</code> so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.</li>
<li>Plugins/Matrix: encrypt E2EE image thumbnails with <code>thumbnail_file</code> while keeping unencrypted-room previews on <code>thumbnail_url</code>, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.</li>
<li>Telegram/forum topics: keep native <code>/new</code> and <code>/reset</code> routed to the active topic by preserving the topic target on forum-thread command context. (#35963)</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.3.28/OpenClaw-2026.3.28.zip" length="25811288" type="application/octet-stream" sparkle:edSignature="SJp4ptVaGlOIXRPevS89DbfN2WKP0bKMXQoaT0fmLhy7pataDfHN0kxC3zu6P0Q/HtsxaESEhJUw48SCUNNKDA=="/>
</item>
<item>
<title>2026.3.24</title>
<pubDate>Wed, 25 Mar 2026 17:06:31 +0000</pubDate>
@@ -95,81 +236,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.23/OpenClaw-2026.3.23.zip" length="24522883" type="application/octet-stream" sparkle:edSignature="ptBgHYLBqq/TSdONYCfIB5d6aP/ij/9G0gYQ5mJI9jf8Y31sbQIh5CqpJVxEEWLTMIGQKsHQir/kXZjtRvvZAg=="/>
</item>
<item>
<title>2026.3.13</title>
<pubDate>Sat, 14 Mar 2026 05:19:48 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026031390</sparkle:version>
<sparkle:shortVersionString>2026.3.13</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.13</h2>
<h3>Changes</h3>
<ul>
<li>Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.</li>
<li>iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show <code>/pair qr</code> instructions on the connect step. (#45054) Thanks @ngutman.</li>
<li>Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for <code>chrome://inspect/#remote-debugging</code> enablement and direct backlinks to Chromes own setup guides.</li>
<li>Browser/agents: add built-in <code>profile="user"</code> for the logged-in host browser and <code>profile="chrome-relay"</code> for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra <code>browserSession</code> selector.</li>
<li>Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.</li>
<li>Docker/timezone override: add <code>OPENCLAW_TZ</code> so <code>docker-setup.sh</code> can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.</li>
<li>Dependencies/pi: bump <code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, and <code>@mariozechner/pi-tui</code> to <code>0.58.0</code>.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.</li>
<li>Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging <code>GatewayClient.request()</code> promises indefinitely.</li>
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
<li>Ollama/reasoning visibility: stop promoting native <code>thinking</code> and <code>reasoning</code> fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.</li>
<li>Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.</li>
<li>Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.</li>
<li>Browser/existing-session: accept text-only <code>list_pages</code> and <code>new_page</code> responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.</li>
<li>Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.</li>
<li>Gateway/session reset: preserve <code>lastAccountId</code> and <code>lastThreadId</code> across gateway session resets so replies keep routing back to the same account and thread after <code>/reset</code>. (#44773) Thanks @Lanfei.</li>
<li>macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so <code>openclaw onboard --install-daemon</code> no longer false-fails on slower Macs and fresh VM snapshots.</li>
<li>Gateway/status: add <code>openclaw gateway status --require-rpc</code> and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.</li>
<li>macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered <code>system.run</code> requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.</li>
<li>Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.</li>
<li>Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.</li>
<li>Windows/gateway install: bound <code>schtasks</code> calls and fall back to the Startup-folder login item when task creation hangs, so native <code>openclaw gateway install</code> fails fast instead of wedging forever on broken Scheduled Task setups.</li>
<li>Windows/gateway stop: resolve Startup-folder fallback listeners from the installed <code>gateway.cmd</code> port, so <code>openclaw gateway stop</code> now actually kills fallback-launched gateway processes before restart.</li>
<li>Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in <code>gateway status --json</code> instead of falling back to <code>gateway port unknown</code>.</li>
<li>Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale <code>device signature expired</code> fallback noise before succeeding.</li>
<li>Discord/gateway startup: treat plain-text and transient <code>/gateway/bot</code> metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.</li>
<li>Slack/probe: keep <code>auth.test()</code> bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.</li>
<li>Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.</li>
<li>Dashboard/chat UI: restore the <code>chat-new-messages</code> class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.</li>
<li>Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.</li>
<li>macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.</li>
<li>Discord/allowlists: honor raw <code>guild_id</code> when hydrated guild objects are missing so allowlisted channels and threads like <code>#maintainers</code> no longer get false-dropped before channel allowlist checks.</li>
<li>macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.</li>
<li>Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.</li>
<li>Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to <code>google-vertex</code> model refs and provider configs so <code>google-vertex/gemini-3.1-flash-lite</code> resolves as <code>gemini-3.1-flash-lite-preview</code>. (#42435) thanks @scoootscooob.</li>
<li>iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.</li>
<li>Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.</li>
<li>Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.</li>
<li>Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed <code>EXTERNAL_UNTRUSTED_CONTENT</code> markers fall back to the existing hardening path instead of bypassing marker normalization.</li>
<li>Security/exec approvals: unwrap more <code>pnpm</code> runtime forms during approval binding, including <code>pnpm --reporter ... exec</code> and direct <code>pnpm node</code> file runs, with matching regression coverage and docs updates.</li>
<li>Security/exec approvals: fail closed for Perl <code>-M</code> and <code>-I</code> approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.</li>
<li>Security/exec approvals: recognize PowerShell <code>-File</code> and <code>-f</code> wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing <code>-Command</code> variants.</li>
<li>Security/exec approvals: unwrap <code>env</code> dispatch wrappers inside shell-segment allowlist resolution on macOS so <code>env FOO=bar /path/to/bin</code> resolves against the effective executable instead of the wrapper token.</li>
<li>Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued <code>$(</code> substitutions fail closed instead of slipping past command-substitution checks.</li>
<li>Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.</li>
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
<li>Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.</li>
<li>Agents/OpenAI-compatible compat overrides: respect explicit user <code>models[].compat</code> opt-ins for non-native <code>openai-completions</code> endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.</li>
<li>Agents/Azure OpenAI startup prompts: rephrase the built-in <code>/new</code>, <code>/reset</code>, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.</li>
<li>Agents/memory bootstrap: load only one root memory file, preferring <code>MEMORY.md</code> and using <code>memory.md</code> as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.</li>
<li>Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.</li>
<li>Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.</li>
<li>Agents/tool warnings: distinguish gated core tools like <code>apply_patch</code> from plugin-only unknown entries in <code>tools.profile</code> warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.</li>
<li>Config/validation: accept documented <code>agents.list[].params</code> per-agent overrides in strict config validation so <code>openclaw config validate</code> no longer rejects runtime-supported <code>cacheRetention</code>, <code>temperature</code>, and <code>maxTokens</code> settings. (#41171) Thanks @atian8179.</li>
<li>Config/web fetch: restore runtime validation for documented <code>tools.web.fetch.readability</code> and <code>tools.web.fetch.firecrawl</code> settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.</li>
<li>Signal/config validation: add <code>channels.signal.groups</code> schema support so per-group <code>requireMention</code>, <code>tools</code>, and <code>toolsBySender</code> overrides no longer get rejected during config validation. (#27199) Thanks @unisone.</li>
<li>Config/discovery: accept <code>discovery.wideArea.domain</code> in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.</li>
<li>Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.</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.3.13/OpenClaw-2026.3.13.zip" length="23640917" type="application/octet-stream" sparkle:edSignature="Me63UHSpFLocTo5Lt7Iqsl0Hq61y3jTcZ9DUkiFl9xQvTE0+ORuqRMFWqPgYwfaKMgcgQmUbrV/uFzEoTIRHBA=="/>
</item>
</channel>
</rss>

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032500
versionName = "2026.3.25"
versionCode = 2026033100
versionName = "2026.3.31"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -31,6 +31,13 @@
android:name="android.hardware.telephony"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".NodeApp"
android:allowBackup="true"

View File

@@ -56,6 +56,17 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
@@ -80,6 +91,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
@@ -197,6 +209,39 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
ensureRuntime().setNotificationForwardingEnabled(value)
}
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
ensureRuntime().setNotificationForwardingMode(mode)
}
fun setNotificationForwardingPackagesCsv(csv: String) {
val packages =
csv
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
ensureRuntime().setNotificationForwardingPackages(packages)
}
fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
}
fun setNotificationForwardingSessionKey(value: String?) {
ensureRuntime().setNotificationForwardingSessionKey(value)
}
fun setVoiceScreenActive(active: Boolean) {
ensureRuntime().setVoiceScreenActive(active)
}
@@ -217,6 +262,22 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().connect(endpoint)
}
fun connect(
endpoint: GatewayEndpoint,
token: String?,
bootstrapToken: String?,
password: String?,
) {
ensureRuntime().connect(
endpoint,
NodeRuntime.GatewayConnectAuth(
token = token,
bootstrapToken = bootstrapToken,
password = password,
),
)
}
fun connectManual() {
ensureRuntime().connectManual()
}

View File

@@ -24,7 +24,6 @@ import ai.openclaw.app.voice.TalkModeManager
import ai.openclaw.app.voice.VoiceConversationEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -34,7 +33,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -47,6 +45,12 @@ class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
) {
data class GatewayConnectAuth(
val token: String?,
val bootstrapToken: String?,
val password: String?,
)
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val deviceAuthStore = DeviceAuthStore(prefs)
@@ -141,6 +145,7 @@ class NodeRuntime(
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 },
@@ -166,6 +171,8 @@ class NodeRuntime(
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() },
@@ -195,7 +202,12 @@ class NodeRuntime(
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val _mainSessionKey = MutableStateFlow("main")
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
val deviceId = identityStore.loadOrCreate().deviceId
return buildNodeMainSessionKey(deviceId, agentId)
}
private val _mainSessionKey = MutableStateFlow(resolveNodeMainSessionKey())
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
private val cameraHudSeq = AtomicLong(0)
@@ -243,7 +255,7 @@ class NodeRuntime(
_serverName.value = name
_remoteAddress.value = remote
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainSessionKey))
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
@@ -259,9 +271,6 @@ class NodeRuntime(
_serverName.value = null
_remoteAddress.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
@@ -320,9 +329,11 @@ class NodeRuntime(
session = operatorSession,
json = json,
supportsChatSubscribe = false,
)
).also {
it.applyMainSessionKey(_mainSessionKey.value)
}
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
@@ -404,13 +415,12 @@ class NodeRuntime(
)
}
private fun applyMainSessionKey(candidate: String?) {
val trimmed = normalizeMainKey(candidate) ?: return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
private fun syncMainSessionKey(agentId: String?) {
val resolvedKey = resolveNodeMainSessionKey(agentId)
if (_mainSessionKey.value == resolvedKey) return
_mainSessionKey.value = resolvedKey
talkMode.setMainSessionKey(resolvedKey)
chat.applyMainSessionKey(resolvedKey)
updateHomeCanvasState()
}
@@ -533,6 +543,17 @@ class NodeRuntime(
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
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
private var didAutoConnect = false
@@ -685,6 +706,34 @@ class NodeRuntime(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
prefs.setNotificationForwardingEnabled(value)
}
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
prefs.setNotificationForwardingMode(mode)
}
fun setNotificationForwardingPackages(packages: List<String>) {
prefs.setNotificationForwardingPackages(packages)
}
fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
prefs.setNotificationForwardingMaxEventsPerMinute(value)
}
fun setNotificationForwardingSessionKey(value: String?) {
prefs.setNotificationForwardingSessionKey(value)
}
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
stopActiveVoiceSession()
@@ -732,28 +781,51 @@ class NodeRuntime(
}
operatorStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun connectWithAuth(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
reconnect: Boolean = false,
) {
val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
val connectOperator =
shouldConnectOperatorSession(
auth.token,
auth.bootstrapToken,
auth.password,
loadStoredRoleDeviceToken("operator"),
)
if (!connectOperator) {
operatorConnected = false
operatorStatusText = "Offline"
operatorSession.disconnect()
updateStatus()
} else {
operatorSession.connect(
endpoint,
auth.token,
auth.bootstrapToken,
auth.password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
}
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
auth.token,
auth.bootstrapToken,
auth.password,
connectionManager.buildNodeConnectOptions(),
tls,
)
operatorSession.reconnect()
nodeSession.reconnect()
if (reconnect && connectOperator) {
operatorSession.reconnect()
}
if (reconnect) {
nodeSession.reconnect()
}
}
fun connect(endpoint: GatewayEndpoint) {
@@ -775,25 +847,27 @@ class NodeRuntime(
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth())
}
fun connect(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
) {
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
}
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth {
return explicitAuth
?: GatewayConnectAuth(
token = prefs.loadGatewayToken(),
bootstrapToken = prefs.loadGatewayBootstrapToken(),
password = prefs.loadGatewayPassword(),
)
}
fun acceptGatewayTrustPrompt() {
@@ -825,6 +899,11 @@ class NodeRuntime(
connect(GatewayEndpoint.manual(host = host, port = port))
}
private fun loadStoredRoleDeviceToken(role: String): String? {
val deviceId = identityStore.loadOrCreate().deviceId
return deviceAuthStore.loadToken(deviceId, role)
}
fun disconnect() {
connectedEndpoint = null
_pendingGatewayTrust.value = null
@@ -960,9 +1039,7 @@ class NodeRuntime(
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey)
syncMainSessionKey(gatewayDefaultAgentId)
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
@@ -995,7 +1072,7 @@ class NodeRuntime(
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
gatewayAgents = agents
applyMainSessionKey(mainKey)
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainKey) ?: gatewayDefaultAgentId)
updateHomeCanvasState()
} catch (_: Throwable) {
// ignore
@@ -1156,6 +1233,20 @@ class NodeRuntime(
}
internal fun shouldConnectOperatorSession(
token: String?,
bootstrapToken: String?,
password: String?,
storedOperatorToken: String?,
): Boolean {
return (
!token.isNullOrBlank() ||
!bootstrapToken.isNullOrBlank() ||
!password.isNullOrBlank() ||
!storedOperatorToken.isNullOrBlank()
)
}
private enum class HomeCanvasGatewayState {
Connected,
Connecting,

View File

@@ -0,0 +1,102 @@
package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
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
}
}
}
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
val packages: Set<String>,
val quietHoursEnabled: Boolean,
val quietStart: String,
val quietEnd: String,
val maxEventsPerMinute: Int,
val sessionKey: String?,
)
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
return false
}
return when (mode) {
NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
}
}
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
): Boolean {
if (!quietHoursEnabled) {
return false
}
val startMinutes = parseLocalHourMinute(quietStart) ?: return false
val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
if (startMinutes == endMinutes) {
return true
}
val now =
Instant.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
return if (startMinutes < endMinutes) {
nowMinutes in startMinutes until endMinutes
} else {
nowMinutes >= startMinutes || nowMinutes < endMinutes
}
}
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
val hour = parts[0].toInt()
val minute = parts[1].toInt()
return hour * 60 + minute
}
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {
windowStartMs = currentWindow
eventsInWindow = 0
}
if (eventsInWindow >= maxEventsPerMinute) {
return false
}
eventsInWindow += 1
return true
}
}
}

View File

@@ -185,7 +185,16 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.READ_SMS -> "Read SMS"
Manifest.permission.READ_CONTACTS -> "Read Contacts"
Manifest.permission.WRITE_CONTACTS -> "Write Contacts"
Manifest.permission.READ_CALENDAR -> "Read Calendar"
Manifest.permission.WRITE_CALENDAR -> "Write Calendar"
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
else -> permission
}
}

View File

@@ -26,6 +26,17 @@ class SecurePrefs(
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
private const val defaultNotificationForwardingEnabled = false
private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
private const val notificationsForwardingQuietHoursEnabledKey =
"notifications.forwarding.quietHoursEnabled"
private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
}
private val appContext = context.applicationContext
@@ -96,6 +107,55 @@ class SecurePrefs(
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
private val _notificationForwardingMode =
MutableStateFlow(
NotificationPackageFilterMode.fromRawValue(
plainPrefs.getString(notificationsForwardingModeKey, null),
),
)
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
val notificationForwardingPackages: StateFlow<Set<String>> = _notificationForwardingPackages
private val storedQuietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
private val storedQuietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
private val storedQuietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
private val _notificationForwardingQuietHoursEnabled =
MutableStateFlow(storedQuietHoursEnabled)
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> = _notificationForwardingQuietHoursEnabled
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
private val _notificationForwardingMaxEventsPerMinute =
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
private val _notificationForwardingSessionKey =
MutableStateFlow(
plainPrefs
.getString(notificationsForwardingSessionKeyKey, "")
?.trim()
?.takeIf { it.isNotEmpty() },
)
val notificationForwardingSessionKey: StateFlow<String?> = _notificationForwardingSessionKey
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
@@ -185,6 +245,114 @@ class SecurePrefs(
_canvasDebugStatusEnabled.value = value
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
val packages =
when (mode) {
NotificationPackageFilterMode.Allowlist -> configuredPackages
NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
}
val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
val quietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
val quietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
val sessionKey =
plainPrefs
.getString(notificationsForwardingSessionKeyKey, "")
?.trim()
?.takeIf { it.isNotEmpty() }
val quietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
return NotificationForwardingPolicy(
enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
mode = mode,
packages = packages,
quietHoursEnabled = quietHoursEnabled,
quietStart = quietStart,
quietEnd = quietEnd,
maxEventsPerMinute = maxEvents.coerceAtLeast(1),
sessionKey = sessionKey,
)
}
internal fun setNotificationForwardingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
_notificationForwardingEnabled.value = value
}
internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
_notificationForwardingMode.value = mode
}
internal fun setNotificationForwardingPackages(packages: List<String>) {
val sanitized =
packages
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
.toList()
.sorted()
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
}
internal fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
if (!enabled) {
plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
_notificationForwardingQuietHoursEnabled.value = false
return true
}
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
plainPrefs.edit {
putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
putString(notificationsForwardingQuietStartKey, normalizedStart)
putString(notificationsForwardingQuietEndKey, normalizedEnd)
}
_notificationForwardingQuietHoursEnabled.value = enabled
_notificationForwardingQuietStart.value = normalizedStart
_notificationForwardingQuietEnd.value = normalizedEnd
return true
}
internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
val normalized = value.coerceAtLeast(1)
plainPrefs.edit {
putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
}
_notificationForwardingMaxEventsPerMinute.value = normalized
}
internal fun setNotificationForwardingSessionKey(value: String?) {
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit {
putString(notificationsForwardingSessionKeyKey, normalized.orEmpty())
}
_notificationForwardingSessionKey.value = normalized
}
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
@@ -308,6 +476,28 @@ class SecurePrefs(
_speakerEnabled.value = value
}
private fun loadNotificationForwardingPackages(): Set<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {
return emptySet()
}
return try {
val element = json.parseToJsonElement(raw)
val array = element as? JsonArray ?: return emptySet()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}
.toSet()
} catch (_: Throwable) {
emptySet()
}
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)

View File

@@ -11,3 +11,14 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
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 }
}
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}

View File

@@ -24,6 +24,7 @@ class ChatController(
private val json: Json,
private val supportsChatSubscribe: Boolean,
) {
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -73,7 +74,7 @@ class ChatController(
}
fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" }
val key = normalizeRequestedSessionKey(sessionKey)
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -81,9 +82,15 @@ class ChatController(
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
val nextState =
applyMainSessionKey(
currentSessionKey = normalizeRequestedSessionKey(_sessionKey.value),
appliedMainSessionKey = appliedMainSessionKey,
nextMainSessionKey = trimmed,
)
appliedMainSessionKey = nextState.appliedMainSessionKey
if (_sessionKey.value == nextState.currentSessionKey) return
_sessionKey.value = nextState.currentSessionKey
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -102,7 +109,7 @@ class ChatController(
}
fun switchSession(sessionKey: String) {
val key = sessionKey.trim()
val key = normalizeRequestedSessionKey(sessionKey)
if (key.isEmpty()) return
if (key == _sessionKey.value) return
_sessionKey.value = key
@@ -111,6 +118,13 @@ class ChatController(
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
}
private fun normalizeRequestedSessionKey(sessionKey: String): String {
val key = sessionKey.trim()
if (key.isEmpty()) return appliedMainSessionKey
if (key == "main" && appliedMainSessionKey != "main") return appliedMainSessionKey
return key
}
fun sendMessage(
message: String,
thinkingLevel: String,
@@ -532,6 +546,28 @@ class ChatController(
}
}
internal data class MainSessionState(
val currentSessionKey: String,
val appliedMainSessionKey: String,
)
internal fun applyMainSessionKey(
currentSessionKey: String,
appliedMainSessionKey: String,
nextMainSessionKey: String,
): MainSessionState {
if (currentSessionKey == appliedMainSessionKey) {
return MainSessionState(
currentSessionKey = nextMainSessionKey,
appliedMainSessionKey = nextMainSessionKey,
)
}
return MainSessionState(
currentSessionKey = currentSessionKey,
appliedMainSessionKey = nextMainSessionKey,
)
}
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming

View File

@@ -181,17 +181,10 @@ class GatewaySession(
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (payloadJson != null) {
put("payloadJSON", JsonPrimitive(payloadJson))
} else {
put("payloadJSON", JsonNull)
}
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
conn.request("node.event", params, timeoutMs = 8_000)

View File

@@ -19,6 +19,7 @@ class ConnectionManager(
private val motionPedometerAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsSearchPossible: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
@@ -82,6 +83,7 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off,
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
smsSearchPossible = smsSearchPossible(),
callLogAvailable = callLogAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import android.Manifest
import android.app.ActivityManager
import android.content.Context
@@ -15,9 +16,9 @@ import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.BuildConfig
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
@@ -28,6 +29,25 @@ class DeviceHandler(
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
) {
companion object {
internal fun hasAnySmsCapability(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
}
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
}
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
@@ -131,6 +151,8 @@ class DeviceHandler(
private fun permissionsPayloadJson(): String {
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val smsSendGranted = hasPermission(Manifest.permission.SEND_SMS)
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (Build.VERSION.SDK_INT >= 33) {
@@ -174,10 +196,34 @@ class DeviceHandler(
)
put(
"sms",
permissionStateJson(
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
buildJsonObject {
put(
"status",
JsonPrimitive(
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
),
)
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
put(
"capabilities",
buildJsonObject {
put(
"send",
permissionStateJson(
granted = smsEnabled && smsSendGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
put(
"read",
permissionStateJson(
granted = smsEnabled && smsReadGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
},
)
},
)
put(
"notificationListener",

View File

@@ -8,6 +8,10 @@ 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 kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -126,6 +130,9 @@ private object DeviceNotificationStore {
}
class DeviceNotificationListenerService : NotificationListenerService() {
private val securePrefs by lazy { SecurePrefs(applicationContext) }
private val forwardingLimiter = NotificationBurstLimiter()
override fun onListenerConnected() {
super.onListenerConnected()
activeService = this
@@ -152,24 +159,12 @@ class DeviceNotificationListenerService : NotificationListenerService() {
super.onNotificationPosted(sbn)
val entry = sbn?.toEntry() ?: return
DeviceNotificationStore.upsert(entry)
rememberRecentPackage(entry.packageName)
if (entry.packageName == packageName) {
return
}
emitNotificationsChanged(
buildJsonObject {
put("change", JsonPrimitive("posted"))
put("key", JsonPrimitive(entry.key))
put("packageName", JsonPrimitive(entry.packageName))
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
put("isOngoing", JsonPrimitive(entry.isOngoing))
put("isClearable", JsonPrimitive(entry.isClearable))
entry.title?.let { put("title", JsonPrimitive(it)) }
entry.text?.let { put("text", JsonPrimitive(it)) }
entry.subText?.let { put("subText", JsonPrimitive(it)) }
entry.category?.let { put("category", JsonPrimitive(it)) }
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
}.toString(),
)
val payload = notificationChangedPayload(entry) ?: return
emitNotificationsChanged(payload)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
@@ -180,21 +175,79 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return
}
DeviceNotificationStore.remove(key)
rememberRecentPackage(removed.packageName)
if (removed.packageName == packageName) {
return
}
emitNotificationsChanged(
buildJsonObject {
put("change", JsonPrimitive("removed"))
put("key", JsonPrimitive(key))
val packageName = removed.packageName.trim()
if (packageName.isNotEmpty()) {
put("packageName", JsonPrimitive(packageName))
}
}.toString(),
val packageName = removed.packageName.trim()
val payload =
notificationChangedPayload(
entry = null,
change = "removed",
key = key,
packageName = packageName,
postTimeMs = removed.postTime,
isOngoing = removed.isOngoing,
isClearable = removed.isClearable,
) ?: return
emitNotificationsChanged(payload)
}
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
return notificationChangedPayload(
entry = entry,
change = "posted",
key = entry.key,
packageName = entry.packageName,
postTimeMs = entry.postTimeMs,
isOngoing = entry.isOngoing,
isClearable = entry.isClearable,
)
}
private fun notificationChangedPayload(
entry: DeviceNotificationEntry?,
change: String,
key: String,
packageName: String,
postTimeMs: Long,
isOngoing: Boolean,
isClearable: Boolean,
): String? {
val normalizedPackage = packageName.trim()
if (normalizedPackage.isEmpty()) {
return null
}
val policy = securePrefs.getNotificationForwardingPolicy(appPackageName = this.packageName)
if (!policy.enabled) {
return null
}
if (!policy.allowsPackage(normalizedPackage)) {
return null
}
val nowEpochMs = System.currentTimeMillis()
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
return null
}
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
return null
}
return buildJsonObject {
put("change", JsonPrimitive(change))
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(normalizedPackage))
put("postTimeMs", JsonPrimitive(postTimeMs))
put("isOngoing", JsonPrimitive(isOngoing))
put("isClearable", JsonPrimitive(isClearable))
policy.sessionKey?.let { put("sessionKey", JsonPrimitive(it)) }
entry?.title?.let { put("title", JsonPrimitive(it)) }
entry?.text?.let { put("text", JsonPrimitive(it)) }
entry?.subText?.let { put("subText", JsonPrimitive(it)) }
entry?.category?.let { put("category", JsonPrimitive(it)) }
entry?.channelId?.let { put("channelId", JsonPrimitive(it)) }
}.toString()
}
private fun refreshActiveNotifications() {
val entries =
runCatching {
@@ -228,6 +281,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
companion object {
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
@@ -239,6 +295,31 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink = sink
}
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()
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
prefs.edit().remove(legacyRecentPackagesPref).apply()
}
}
fun recentPackages(context: Context): List<String> {
migrateLegacyRecentPackagesIfNeeded(context)
val prefs = recentPackagesPrefs(context)
val stored = prefs.getString(recentPackagesPref, null).orEmpty()
return stored
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
@@ -276,6 +357,21 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
}
}
private fun rememberRecentPackage(packageName: String?) {
val service = activeService ?: return
val normalized = packageName?.trim().orEmpty()
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 updated = listOf(normalized) + existing
prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
}
}
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {

View File

@@ -20,6 +20,7 @@ data class NodeRuntimeFlags(
val locationEnabled: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val smsSearchPossible: Boolean,
val callLogAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
@@ -33,6 +34,7 @@ enum class InvokeCommandAvailability {
LocationEnabled,
SendSmsAvailable,
ReadSmsAvailable,
RequestableSmsSearchAvailable,
CallLogAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
@@ -199,7 +201,7 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
availability = InvokeCommandAvailability.RequestableSmsSearchAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@@ -244,6 +246,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable

View File

@@ -14,6 +14,44 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
internal enum class SmsSearchAvailabilityReason {
Available,
PermissionRequired,
Unavailable,
}
internal fun classifySmsSearchAvailability(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): SmsSearchAvailabilityReason {
if (readSmsAvailable) return SmsSearchAvailabilityReason.Available
if (!smsFeatureEnabled || !smsTelephonyAvailable) return SmsSearchAvailabilityReason.Unavailable
return SmsSearchAvailabilityReason.PermissionRequired
}
internal fun smsSearchAvailabilityError(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): GatewaySession.InvokeResult? {
return when (
classifySmsSearchAvailability(
readSmsAvailable = readSmsAvailable,
smsFeatureEnabled = smsFeatureEnabled,
smsTelephonyAvailable = smsTelephonyAvailable,
)
) {
SmsSearchAvailabilityReason.Available,
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,
private val cameraHandler: CameraHandler,
@@ -34,6 +72,8 @@ class InvokeDispatcher(
private val locationEnabled: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsFeatureEnabled: () -> Boolean,
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
@@ -268,15 +308,13 @@ class InvokeDispatcher(
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
smsTelephonyAvailable = smsTelephonyAvailable(),
)
InvokeCommandAvailability.CallLogAvailable ->
if (callLogAvailable()) {
null

View File

@@ -9,23 +9,28 @@ class SmsHandler(
val res = sms.send(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEARCH_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
}
private fun errorResult(error: String?, defaultCode: String): GatewaySession.InvokeResult {
val rawMessage = error ?: defaultCode
val idx = rawMessage.indexOf(':')
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
val message =
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
} else {
rawMessage
}
return GatewaySession.InvokeResult.error(code = code, message = message)
}
}

View File

@@ -3,21 +3,21 @@ package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
@@ -39,7 +39,7 @@ class SmsManager(private val context: Context) {
)
/**
* Represents a single SMS message
* Represents a single SMS message.
*/
@Serializable
data class SmsMessage(
@@ -53,6 +53,7 @@ class SmsManager(private val context: Context) {
val type: Int,
val body: String?,
val status: Int,
val transportType: String? = null,
)
data class SearchResult(
@@ -62,6 +63,13 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
internal data class QueryMetadata(
val mmsRequested: Boolean,
val mmsEligible: Boolean,
val mmsAttempted: Boolean,
val mmsIncluded: Boolean,
)
internal data class ParsedParams(
val to: String,
val message: String,
@@ -84,6 +92,8 @@ class SmsManager(private val context: Context) {
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val includeMms: Boolean = false,
val conversationReview: Boolean = false,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
@@ -100,6 +110,11 @@ class SmsManager(private val context: Context) {
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal const val MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW = 500
private const val MMS_SMS_BY_PHONE_BASE = "content://mms-sms/messages/byphone"
private const val MMS_CONTENT_BASE = "content://mms"
private const val MMS_PART_URI = "content://mms/part"
private val PHONE_FORMATTING_REGEX = Regex("""[\s\-()]""")
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@@ -157,31 +172,333 @@ class SmsManager(private val context: Context) {
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
val includeMms = (obj["includeMms"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val conversationReview = (obj["conversationReview"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
.coerceIn(1, 200)
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
// Validate time range
if (startTime != null && endTime != null && startTime > endTime) {
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
}
return QueryParseResult.Ok(QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
limit = limit,
offset = offset,
))
return QueryParseResult.Ok(
QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
includeMms = includeMms,
conversationReview = conversationReview,
limit = limit,
offset = offset,
)
)
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
return phone.replace(PHONE_FORMATTING_REGEX, "")
}
internal fun normalizePhoneNumberOrNull(phone: String?): String? {
val normalized = phone?.let(::normalizePhoneNumber)?.trim().orEmpty()
if (normalized.isEmpty()) {
return null
}
val digits = toByPhoneLookupNumber(normalized)
return normalized.takeIf { digits.isNotEmpty() }
}
internal fun sanitizeContactPhoneNumberOrNull(phone: String?): String? {
val normalized = normalizePhoneNumberOrNull(phone) ?: return null
return normalized.takeUnless(::hasSqlLikeWildcard)
}
internal fun shouldPromptForContactNameSearchPermission(
contactName: String?,
phoneNumber: String?,
hasReadContactsPermission: Boolean,
): Boolean {
return !contactName.isNullOrEmpty() && phoneNumber.isNullOrEmpty() && !hasReadContactsPermission
}
internal fun mapMmsMsgBoxToSearchType(msgBox: Int?): Int? {
return when (msgBox) {
1 -> 1 // inbox
2 -> 2 // sent
3 -> 3 // draft
4 -> 4 // outbox
5 -> 5 // failed
6 -> 6 // queued
else -> null
}
}
internal fun escapeSqlLikeLiteral(value: String): String {
return buildString(value.length) {
for (ch in value) {
when (ch) {
'\\', '%', '_' -> {
append('\\')
append(ch)
}
else -> append(ch)
}
}
}
}
internal fun buildContactNameLikeSelection(): String {
return "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? ESCAPE '\\'"
}
internal fun buildContactNameLikeArg(contactName: String): String {
return "%${escapeSqlLikeLiteral(contactName)}%"
}
internal fun buildKeywordLikeSelection(): String {
return "${Telephony.Sms.BODY} LIKE ? ESCAPE '\\'"
}
internal fun buildKeywordLikeArg(keyword: String): String {
return "%${escapeSqlLikeLiteral(keyword)}%"
}
internal fun buildMixedByPhoneProjection(): Array<String> {
return arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
)
}
internal fun hasSqlLikeWildcard(value: String): Boolean {
return value.contains('%') || value.contains('_')
}
internal fun isExplicitPhoneInputInvalid(rawPhone: String?, normalizedPhone: String?): Boolean {
if (rawPhone.isNullOrBlank()) {
return false
}
if (normalizedPhone == null) {
return true
}
return hasSqlLikeWildcard(normalizedPhone)
}
internal fun resolveMixedByPhoneRowStatus(transportType: String?, smsStatus: Int?): Int {
return if (transportType.equals("mms", ignoreCase = true)) -1 else (smsStatus ?: 0)
}
internal fun resolveMixedByPhoneRowAddress(
providerAddress: String?,
phoneNumber: String,
mmsAddress: String? = null,
): String? {
val resolvedMmsAddress = normalizePhoneNumberOrNull(mmsAddress)
if (resolvedMmsAddress != null) {
return resolvedMmsAddress
}
val resolvedProviderAddress = normalizePhoneNumberOrNull(providerAddress)
return resolvedProviderAddress ?: phoneNumber
}
internal fun selectPreferredMmsAddress(
addressRows: List<Pair<String?, Int?>>,
lookupNumber: String,
): String? {
val lookupDigits = toByPhoneLookupNumber(lookupNumber)
val normalizedRows = addressRows.mapNotNull { (address, type) ->
val normalized = normalizePhoneNumberOrNull(address) ?: return@mapNotNull null
val digits = toByPhoneLookupNumber(normalized)
if (digits.isBlank()) return@mapNotNull null
Triple(normalized, digits, type)
}
fun firstPreferred(vararg types: Int): String? {
return normalizedRows.firstOrNull { row ->
(types.isEmpty() || types.contains(row.third ?: -1)) && row.second != lookupDigits
}?.first
}
return firstPreferred(137)
?: firstPreferred(151, 130, 129)
?: firstPreferred()
?: normalizedRows.firstOrNull()?.first
}
internal fun shouldUseConversationReviewByPhoneMode(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): Boolean {
val hasExplicitPhoneNumber = !params.phoneNumber.isNullOrEmpty()
val hasSingleResolvedPhoneNumber = resolvedPhoneNumbers.size == 1
return params.conversationReview && params.includeMms && (hasExplicitPhoneNumber || hasSingleResolvedPhoneNumber)
}
internal fun effectiveSearchParams(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
if (!shouldUseConversationReviewByPhoneMode(params, resolvedPhoneNumbers)) return params
val reviewLimit = maxOf(params.limit, 25)
return params.copy(limit = reviewLimit)
}
internal fun resolveSearchParams(
params: QueryParams,
normalizedPhoneNumber: String?,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
val effectivePhoneNumber = normalizedPhoneNumber ?: resolvedPhoneNumbers.singleOrNull()
val normalizedParams = params.copy(phoneNumber = effectivePhoneNumber)
return effectiveSearchParams(normalizedParams, resolvedPhoneNumbers)
}
internal fun toByPhoneLookupNumber(phone: String): String {
return phone.filter { it.isDigit() }
}
internal fun normalizeProviderDateMillis(rawDate: Long): Long {
return if (rawDate in 1..99_999_999_999L) rawDate * 1000L else rawDate
}
internal fun canonicalizeMixedPathPhoneFilters(phoneNumbers: List<String>): List<String> {
return phoneNumbers
.map(::toByPhoneLookupNumber)
.filter { it.isNotBlank() }
.distinct()
}
internal fun requestedMixedByPhoneCandidateWindow(params: QueryParams): Long {
return params.offset.toLong() + params.limit.toLong()
}
internal fun exceedsMixedByPhoneCandidateWindow(
params: QueryParams,
allPhoneNumbers: List<String>,
): Boolean {
return params.includeMms &&
allPhoneNumbers.size == 1 &&
requestedMixedByPhoneCandidateWindow(params) > MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW
}
internal fun mixedByPhoneWindowError(): String {
return "INVALID_REQUEST: includeMms offset+limit exceeds supported window ($MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW)"
}
internal fun isMmsTransportRow(message: SmsMessage): Boolean {
return message.transportType.equals("mms", ignoreCase = true)
}
internal fun shouldHydrateMmsByPhoneRow(transportType: String?, body: String?, type: Int): Boolean {
return transportType.equals("mms", ignoreCase = true) && (body.isNullOrBlank() || type == 0)
}
internal fun buildQueryMetadata(
params: QueryParams,
allPhoneNumbers: List<String>,
messages: List<SmsMessage>,
): QueryMetadata {
val mmsRequested = params.includeMms
val mmsEligible = mmsRequested && allPhoneNumbers.size == 1
val mmsAttempted = mmsEligible
val mmsIncluded = mmsAttempted && messages.any(::isMmsTransportRow)
return QueryMetadata(
mmsRequested = mmsRequested,
mmsEligible = mmsEligible,
mmsAttempted = mmsAttempted,
mmsIncluded = mmsIncluded,
)
}
internal fun compareByPhoneCandidateOrder(left: SmsMessage, right: SmsMessage): Int {
return when {
left.date != right.date -> right.date.compareTo(left.date)
left.id != right.id -> right.id.compareTo(left.id)
else -> 0
}
}
internal fun buildMixedRowIdentity(rowId: Long, transportType: String?): String {
return "${transportType?.ifBlank { "unknown" } ?: "unknown"}:$rowId"
}
internal fun upsertTopDateCandidates(
candidates: MutableList<Pair<String, SmsMessage>>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
) {
if (maxCandidates <= 0) {
return
}
candidates.removeAll { existing -> existing.first == identityKey }
candidates.add(identityKey to message)
candidates.sortWith { left, right -> compareByPhoneCandidateOrder(left.second, right.second) }
while (candidates.size > maxCandidates) {
candidates.removeAt(candidates.lastIndex)
}
}
internal fun materializeByPhoneCandidate(
candidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
) {
candidates[identityKey] = message
}
internal fun collectMixedByPhoneCandidate(
topCandidates: MutableList<Pair<String, SmsMessage>>,
materializedCandidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
reviewMode: Boolean,
) {
if (reviewMode) {
materializeByPhoneCandidate(materializedCandidates, identityKey, message)
} else {
upsertTopDateCandidates(topCandidates, identityKey, message, maxCandidates)
}
}
internal fun pageMixedByPhoneCandidates(
topCandidates: Collection<Pair<String, SmsMessage>>,
materializedCandidates: Map<String, SmsMessage>,
params: QueryParams,
reviewMode: Boolean,
): List<SmsMessage> {
return if (reviewMode) {
pageByPhoneCandidates(materializedCandidates.values, params)
} else {
pageByPhoneCandidates(topCandidates.map { it.second }, params)
}
}
internal fun pageByPhoneCandidates(
candidates: Collection<SmsMessage>,
params: QueryParams,
): List<SmsMessage> {
return candidates
.sortedWith(::compareByPhoneCandidateOrder)
.drop(params.offset)
.take(params.limit)
}
internal fun buildSendPlan(
@@ -214,14 +531,21 @@ class SmsManager(private val context: Context) {
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
queryMetadata: QueryMetadata? = null,
): String {
val messagesArray = json.encodeToString(messages)
val messagesElement = json.parseToJsonElement(messagesArray)
val payload = mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"count" to JsonPrimitive(messages.size),
"messages" to messagesElement
"messages" to messagesElement,
)
queryMetadata?.let {
payload["mmsRequested"] = JsonPrimitive(it.mmsRequested)
payload["mmsEligible"] = JsonPrimitive(it.mmsEligible)
payload["mmsAttempted"] = JsonPrimitive(it.mmsAttempted)
payload["mmsIncluded"] = JsonPrimitive(it.mmsIncluded)
}
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
@@ -254,10 +578,14 @@ class SmsManager(private val context: Context) {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
fun canSearchSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return canSearchSms()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@@ -302,19 +630,19 @@ class SmsManager(private val context: Context) {
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
params.to,
null,
ArrayList(plan.parts),
null,
null,
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
params.to,
null,
params.message,
null,
null,
)
}
@@ -334,6 +662,82 @@ class SmsManager(private val context: Context) {
}
}
/**
* Search SMS messages with the specified parameters.
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext queryError("SMS_UNAVAILABLE: telephony not available")
}
if (!ensureReadSmsPermission()) {
return@withContext queryError("SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext queryError(parseResult.error)
}
val parsedParams = (parseResult as QueryParseResult.Ok).params
val normalizedPhoneNumber = normalizePhoneNumberOrNull(parsedParams.phoneNumber)
if (isExplicitPhoneInputInvalid(parsedParams.phoneNumber, normalizedPhoneNumber)) {
val error =
if (!parsedParams.phoneNumber.isNullOrBlank() && normalizedPhoneNumber != null && hasSqlLikeWildcard(normalizedPhoneNumber)) {
"INVALID_REQUEST: phoneNumber must not contain SQL LIKE wildcard characters"
} else {
"INVALID_REQUEST: phoneNumber must contain at least one digit"
}
return@withContext queryError(error)
}
val normalizedParams = resolveSearchParams(parsedParams, normalizedPhoneNumber)
return@withContext try {
val contactsPermissionGranted = hasReadContactsPermission()
val shouldPromptForContactsPermission =
shouldPromptForContactNameSearchPermission(
contactName = normalizedParams.contactName,
phoneNumber = normalizedParams.phoneNumber,
hasReadContactsPermission = contactsPermissionGranted,
)
val phoneNumbers = if (!normalizedParams.contactName.isNullOrEmpty()) {
if (contactsPermissionGranted || (shouldPromptForContactsPermission && ensureReadContactsPermission())) {
getPhoneNumbersFromContactName(normalizedParams.contactName)
} else if (shouldPromptForContactsPermission) {
return@withContext queryError("CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
} else {
emptyList()
}
} else {
emptyList()
}
val params = resolveSearchParams(parsedParams, normalizedPhoneNumber, phoneNumbers)
val mixedPathPhoneFilters = if (!params.phoneNumber.isNullOrEmpty()) {
canonicalizeMixedPathPhoneFilters(phoneNumbers + params.phoneNumber)
} else {
canonicalizeMixedPathPhoneFilters(phoneNumbers)
}
if (exceedsMixedByPhoneCandidateWindow(params, mixedPathPhoneFilters)) {
val error = mixedByPhoneWindowError()
return@withContext queryError(error)
}
if (!params.contactName.isNullOrEmpty() && phoneNumbers.isEmpty() && params.phoneNumber.isNullOrEmpty()) {
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, emptyList())
return@withContext queryOk(emptyList(), queryMetadata)
}
val messages = querySmsMessages(params, phoneNumbers)
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, messages)
queryOk(messages, queryMetadata)
} catch (e: SecurityException) {
queryError("SMS_PERMISSION_REQUIRED: ${e.message}")
} catch (e: Throwable) {
queryError("SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
@@ -375,98 +779,31 @@ class SmsManager(private val context: Context) {
)
}
/**
* search SMS messages with the specified parameters.
*
* @param paramsJson JSON with optional fields:
* - startTime (Long): Start time in milliseconds
* - endTime (Long): End time in milliseconds
* - contactName (String): Contact name to search
* - phoneNumber (String): Phone number to search (supports partial matching)
* - keyword (String): Keyword to search in message body
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
* - isRead (Boolean): Read status
* - limit (Int): Number of records to return (default: 25, range: 1-200)
* - offset (Int): Number of records to skip (default: 0)
* @return SearchResult containing the list of SMS messages or an error
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_UNAVAILABLE: telephony not available",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
)
}
if (!ensureReadSmsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
)
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = parseResult.error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
)
}
val params = (parseResult as QueryParseResult.Ok).params
return@withContext try {
// Get phone numbers from contact name if provided
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
if (!ensureReadContactsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
)
}
getPhoneNumbersFromContactName(params.contactName)
} else {
emptyList()
}
val messages = querySmsMessages(params, phoneNumbers)
SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
)
} catch (e: SecurityException) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
)
} catch (e: Throwable) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
)
}
private fun queryOk(
messages: List<SmsMessage>,
queryMetadata: QueryMetadata? = null,
): SearchResult {
return SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages, queryMetadata = queryMetadata),
)
}
private fun queryError(error: String): SearchResult {
return SearchResult(
ok = false,
messages = emptyList(),
error = error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = error),
)
}
/**
* Get all phone numbers associated with a contact name
*/
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
val phoneNumbers = mutableListOf<String>()
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("%$contactName%")
val selection = buildContactNameLikeSelection()
val selectionArgs = arrayOf(buildContactNameLikeArg(contactName))
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
@@ -480,26 +817,19 @@ class SmsManager(private val context: Context) {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
sanitizeContactPhoneNumberOrNull(number)?.let(phoneNumbers::add)
}
}
return phoneNumbers
}
/**
* Query SMS messages based on the provided parameters
*/
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
// Build selection and selectionArgs
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
// Time range
if (params.startTime != null) {
selections.add("${Telephony.Sms.DATE} >= ?")
selectionArgs.add(params.startTime.toString())
@@ -509,11 +839,17 @@ class SmsManager(private val context: Context) {
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
(phoneNumbers + normalizePhoneNumber(params.phoneNumber)).distinct()
} else {
phoneNumbers
phoneNumbers.distinct()
}
val mixedPathPhoneFilters = canonicalizeMixedPathPhoneFilters(allPhoneNumbers)
// Unified SMS+MMS query path is opt-in to keep sms.search semantics
// stable by default. Use includeMms=true for by-phone provider behavior.
if (params.includeMms && mixedPathPhoneFilters.size == 1) {
return querySmsMmsMessagesByPhone(mixedPathPhoneFilters.first(), params)
}
if (allPhoneNumbers.isNotEmpty()) {
@@ -526,19 +862,16 @@ class SmsManager(private val context: Context) {
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
selections.add(buildKeywordLikeSelection())
selectionArgs.add(buildKeywordLikeArg(params.keyword))
}
// Type
if (params.type != null) {
selections.add("${Telephony.Sms.TYPE} = ?")
selectionArgs.add(params.type.toString())
}
// Read status
if (params.isRead != null) {
selections.add("${Telephony.Sms.READ} = ?")
selectionArgs.add(if (params.isRead) "1" else "0")
@@ -556,7 +889,8 @@ class SmsManager(private val context: Context) {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
// Android SMS providers still honor LIMIT/OFFSET through sortOrder on this path.
// Keep the bounded interpolation here because parseQueryParams already clamps both values.
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
@@ -570,7 +904,7 @@ class SmsManager(private val context: Context) {
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
Telephony.Sms.STATUS,
),
selection,
selectionArgsArray,
@@ -601,7 +935,7 @@ class SmsManager(private val context: Context) {
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
status = it.getInt(statusIndex),
)
messages.add(message)
count++
@@ -610,4 +944,184 @@ class SmsManager(private val context: Context) {
return messages
}
private fun querySmsMmsMessagesByPhone(phoneNumber: String, params: QueryParams): List<SmsMessage> {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return emptyList()
}
val uri = Uri.parse("$MMS_SMS_BY_PHONE_BASE/${Uri.encode(lookupNumber)}")
val projection = buildMixedByPhoneProjection()
val maxCandidates = params.offset + params.limit
if (maxCandidates <= 0) {
return emptyList()
}
val reviewMode = shouldUseConversationReviewByPhoneMode(params)
val topCandidates = mutableListOf<Pair<String, SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsMessage>()
val cursor = context.contentResolver.query(uri, projection, null, null, "date DESC")
cursor?.use {
val idIndex = it.getColumnIndex("_id")
val threadIdIndex = it.getColumnIndex("thread_id")
val transportTypeIndex = it.getColumnIndex("transport_type")
val addressIndex = it.getColumnIndex("address")
val dateIndex = it.getColumnIndex("date")
val dateSentIndex = it.getColumnIndex("date_sent")
val readIndex = it.getColumnIndex("read")
val typeIndex = it.getColumnIndex("type")
val bodyIndex = it.getColumnIndex("body")
val statusIndex = it.getColumnIndex("status")
while (it.moveToNext()) {
val id = if (idIndex >= 0 && !it.isNull(idIndex)) it.getLong(idIndex) else continue
val rawDate = if (dateIndex >= 0 && !it.isNull(dateIndex)) it.getLong(dateIndex) else 0L
val dateMs = normalizeProviderDateMillis(rawDate)
if (params.startTime != null && dateMs < params.startTime) continue
if (params.endTime != null && dateMs > params.endTime) continue
val threadId = if (threadIdIndex >= 0 && !it.isNull(threadIdIndex)) it.getLong(threadIdIndex) else 0L
val transportType = if (transportTypeIndex >= 0 && !it.isNull(transportTypeIndex)) it.getString(transportTypeIndex) else null
val providerAddress = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val mmsAddress = if (transportType.equals("mms", ignoreCase = true)) getMmsAddress(id, phoneNumber) else null
val address = resolveMixedByPhoneRowAddress(providerAddress, phoneNumber, mmsAddress)
var read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else true
var type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else 0
var body = if (bodyIndex >= 0 && !it.isNull(bodyIndex)) it.getString(bodyIndex) else null
val smsStatus = if (statusIndex >= 0 && !it.isNull(statusIndex)) it.getInt(statusIndex) else null
// Only MMS transport rows are allowed to hydrate from MMS storage.
if (shouldHydrateMmsByPhoneRow(transportType, body, type)) {
body = body?.takeIf { msg -> msg.isNotBlank() } ?: getMmsTextBody(id)
val mmsMeta = getMmsMeta(id)
if (type == 0) {
type = mmsMeta.first ?: type
}
if (readIndex < 0 || it.isNull(readIndex)) {
read = mmsMeta.second ?: read
}
}
val dateSentRaw = if (dateSentIndex >= 0 && !it.isNull(dateSentIndex)) it.getLong(dateSentIndex) else 0L
val dateSentMs = normalizeProviderDateMillis(dateSentRaw)
if (!params.keyword.isNullOrEmpty()) {
val keyword = params.keyword
if (body.isNullOrEmpty() || !body.contains(keyword, ignoreCase = true)) {
continue
}
}
if (params.type != null && type != params.type) continue
if (params.isRead != null && read != params.isRead) continue
val message = SmsMessage(
id = id,
threadId = threadId,
address = address,
person = null,
date = dateMs,
dateSent = dateSentMs,
read = read,
type = type,
body = body,
status = resolveMixedByPhoneRowStatus(transportType, smsStatus),
transportType = transportType,
)
val identityKey = buildMixedRowIdentity(id, transportType)
collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = identityKey,
message = message,
maxCandidates = maxCandidates,
reviewMode = reviewMode,
)
}
}
return pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = reviewMode,
)
}
private fun getMmsTextBody(messageId: Long): String? {
val cursor = context.contentResolver.query(
Uri.parse(MMS_PART_URI),
arrayOf("text", "ct"),
"mid=?",
arrayOf(messageId.toString()),
null,
)
cursor?.use {
val textIndex = it.getColumnIndex("text")
val ctIndex = it.getColumnIndex("ct")
while (it.moveToNext()) {
val contentType = if (ctIndex >= 0 && !it.isNull(ctIndex)) it.getString(ctIndex) else null
if (contentType != null && contentType != "text/plain") continue
val text = if (textIndex >= 0 && !it.isNull(textIndex)) it.getString(textIndex) else null
if (!text.isNullOrBlank()) return text
}
}
return null
}
private fun getMmsMeta(messageId: Long): Pair<Int?, Boolean?> {
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId"),
arrayOf("msg_box", "read"),
null,
null,
null,
)
cursor?.use {
if (it.moveToFirst()) {
val msgBoxIndex = it.getColumnIndex("msg_box")
val readIndex = it.getColumnIndex("read")
val msgBox = if (msgBoxIndex >= 0 && !it.isNull(msgBoxIndex)) it.getInt(msgBoxIndex) else null
val mappedType = mapMmsMsgBoxToSearchType(msgBox)
val read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else null
return mappedType to read
}
}
return null to null
}
private fun getMmsAddress(messageId: Long, phoneNumber: String): String? {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return null
}
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId/addr"),
arrayOf("address", "type"),
null,
null,
null,
)
cursor?.use {
val addressIndex = it.getColumnIndex("address")
val typeIndex = it.getColumnIndex("type")
val addressRows = mutableListOf<Pair<String?, Int?>>()
while (it.moveToNext()) {
val address = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else null
addressRows.add(address to type)
}
return selectPreferredMmsAddress(addressRows, lookupNumber)
}
return null
}
}

View File

@@ -53,6 +53,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode {
@@ -71,6 +72,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
@@ -240,9 +242,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
resolveGatewayConnectConfig(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
manualHost = manualHostInput,
manualPort = manualPortInput,
manualTls = manualTlsInput,
savedManualHost = manualHost,
savedManualPort = manualPort.toString(),
savedManualTls = manualTls,
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
fallbackBootstrapToken = gatewayBootstrapToken,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
)
@@ -269,7 +275,12 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()
viewModel.connect(
GatewayEndpoint.manual(host = config.host, port = config.port),
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
)
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),

View File

@@ -37,9 +37,13 @@ private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
manualHost: String,
manualPort: String,
manualTls: Boolean,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
fallbackBootstrapToken: String,
fallbackToken: String,
fallbackPassword: String,
): GatewayConnectConfig? {
@@ -69,13 +73,23 @@ internal fun resolveGatewayConnectConfig(
)
}
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let(::parseGatewayEndpoint)
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
savedManualEndpoint.port == parsed.port &&
savedManualEndpoint.tls == parsed.tls &&
fallbackToken.isBlank() &&
fallbackPassword.isBlank()
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
bootstrapToken = "",
bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)

View File

@@ -96,6 +96,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
@@ -211,6 +212,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val persistedGatewayToken by viewModel.gatewayToken.collectAsState()
@@ -227,6 +229,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var manualTls by rememberSaveable { mutableStateOf(false) }
var gatewayError by rememberSaveable { mutableStateOf<String?>(null) }
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
val canFinishOnboarding = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
val lifecycleOwner = LocalLifecycleOwner.current
val qrScannerOptions =
@@ -732,7 +735,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
FinalStep(
parsedGateway = parseGatewayEndpoint(gatewayUrl),
statusText = statusText,
isConnected = isConnected,
isConnected = canFinishOnboarding,
serverName = serverName,
remoteAddress = remoteAddress,
attemptedConnect = attemptedConnect,
@@ -848,7 +851,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
OnboardingStep.FinalCheck -> {
if (isConnected) {
if (canFinishOnboarding) {
Button(
onClick = { viewModel.setOnboardingCompleted(true) },
modifier = Modifier.weight(1f).height(52.dp),
@@ -882,7 +885,17 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(password)
viewModel.connectManual()
viewModel.connect(
GatewayEndpoint.manual(host = parsed.host, port = parsed.port),
token = token.ifEmpty { null },
bootstrapToken =
if (gatewayInputMode == GatewayInputMode.SetupCode) {
decodeGatewaySetupCode(setupCode)?.bootstrapToken?.trim()?.ifEmpty { null }
} else {
null
},
password = password.ifEmpty { null },
)
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
@@ -898,6 +911,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
internal fun canFinishOnboarding(isConnected: Boolean, isNodeConnected: Boolean): Boolean {
return isConnected && isNodeConnected
}
@Composable
private fun onboardingPrimaryButtonColors() =
ButtonDefaults.buttonColors(
@@ -1459,8 +1476,8 @@ private fun PermissionsStep(
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}
@@ -1677,21 +1694,22 @@ private fun FinalStep(
)
}
}
Text("Status", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingCommandBg,
border = BorderStroke(1.dp, onboardingCommandBorder),
) {
Text(
statusLabel,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
color = onboardingCommandText,
)
}
if (showDiagnostics) {
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingCommandBg,
border = BorderStroke(1.dp, onboardingCommandBorder),
) {
Text(
statusLabel,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
color = onboardingCommandText,
)
}
Text(
"OpenClaw Android ${openClawAndroidVersionLabel()}",
style = onboardingCaption1Style,

View File

@@ -34,7 +34,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
@@ -54,20 +53,23 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.normalizeLocalHourMinute
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.node.DeviceNotificationListenerService
@Composable
@@ -81,6 +83,55 @@ fun SettingsSheet(viewModel: MainViewModel) {
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val notificationForwardingEnabled by viewModel.notificationForwardingEnabled.collectAsState()
val notificationForwardingMode by viewModel.notificationForwardingMode.collectAsState()
val notificationForwardingPackages by viewModel.notificationForwardingPackages.collectAsState()
val notificationForwardingQuietHoursEnabled by viewModel.notificationForwardingQuietHoursEnabled.collectAsState()
val notificationForwardingQuietStart by viewModel.notificationForwardingQuietStart.collectAsState()
val notificationForwardingQuietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
val notificationForwardingMaxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
val notificationForwardingSessionKey by viewModel.notificationForwardingSessionKey.collectAsState()
var notificationQuietStartDraft by remember(notificationForwardingQuietStart) {
mutableStateOf(notificationForwardingQuietStart)
}
var notificationQuietEndDraft by remember(notificationForwardingQuietEnd) {
mutableStateOf(notificationForwardingQuietEnd)
}
var notificationRateDraft by remember(notificationForwardingMaxEventsPerMinute) {
mutableStateOf(notificationForwardingMaxEventsPerMinute.toString())
}
var notificationSessionKeyDraft by remember(notificationForwardingSessionKey) {
mutableStateOf(notificationForwardingSessionKey.orEmpty())
}
val normalizedQuietStartDraft = remember(notificationQuietStartDraft) {
normalizeLocalHourMinute(notificationQuietStartDraft)
}
val normalizedQuietEndDraft = remember(notificationQuietEndDraft) {
normalizeLocalHourMinute(notificationQuietEndDraft)
}
val quietHoursDraftValid = normalizedQuietStartDraft != null && normalizedQuietEndDraft != null
val selectedPackagesSummary = remember(notificationForwardingMode, notificationForwardingPackages) {
when (notificationForwardingMode) {
NotificationPackageFilterMode.Allowlist ->
if (notificationForwardingPackages.isEmpty()) {
"Selected: none — allowlist mode forwards nothing until you add apps."
} else {
"Selected: ${notificationForwardingPackages.size} app(s) allowed."
}
NotificationPackageFilterMode.Blocklist ->
if (notificationForwardingPackages.isEmpty()) {
"Selected: none — blocklist mode forwards all apps except OpenClaw."
} else {
"Selected: ${notificationForwardingPackages.size} app(s) blocked."
}
}
}
val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
val quietHoursDraftDirty =
notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
val quietHoursSaveEnabled = notificationForwardingEnabled && quietHoursDraftValid && quietHoursDraftDirty
val listState = rememberLazyListState()
val deviceModel =
@@ -175,6 +226,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(isNotificationListenerEnabled(context))
}
val notificationForwardingAvailable = notificationForwardingEnabled && notificationListenerEnabled
val notificationForwardingControlsAlpha = if (notificationForwardingAvailable) 1f else 0.6f
var notificationPickerExpanded by remember { mutableStateOf(false) }
var notificationAppSearch by remember { mutableStateOf("") }
var notificationShowSystemApps by remember { mutableStateOf(false) }
var installedNotificationApps by
remember(context, notificationForwardingPackages) {
mutableStateOf(queryInstalledApps(context, notificationForwardingPackages))
}
var photosPermissionGranted by
remember {
@@ -249,16 +310,19 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
viewModel.refreshGatewayConnection()
}
@@ -271,6 +335,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
notificationsPermissionGranted = hasNotificationsPermission(context)
notificationListenerEnabled = isNotificationListenerEnabled(context)
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
photosPermissionGranted =
ContextCompat.checkSelfPermission(context, photosPermission) ==
PackageManager.PERMISSION_GRANTED
@@ -293,7 +358,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
PackageManager.PERMISSION_GRANTED
||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
}
@@ -351,6 +417,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
val normalizedAppSearch = notificationAppSearch.trim().lowercase()
val filteredNotificationApps =
remember(installedNotificationApps, normalizedAppSearch, notificationShowSystemApps) {
installedNotificationApps
.asSequence()
.filter { app -> notificationShowSystemApps || !app.isSystemApp }
.filter { app ->
normalizedAppSearch.isEmpty() ||
app.label.lowercase().contains(normalizedAppSearch) ||
app.packageName.lowercase().contains(normalizedAppSearch)
}
.toList()
}
Box(
modifier =
Modifier
@@ -491,9 +571,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Notification Listener", style = mobileHeadline) },
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
supportingContent = {
Text("Read and interact with notifications.", style = mobileCallout)
Text(
"Required for `notifications.list`, `notifications.actions`, and forwarded notification events.",
style = mobileCallout,
)
},
trailingContent = {
Button(
@@ -530,7 +613,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp),
) {
Text(
if (smsPermissionGranted) "Manage" else "Grant",
if (smsPermissionGranted) {
"Manage"
} else {
"Grant"
},
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
@@ -539,6 +626,297 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
}
item {
ListItem(
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Forward Notification Events", style = mobileHeadline) },
supportingContent = {
Text(
if (notificationListenerEnabled) {
"Forward listener events into gateway node events. Off by default until you enable it."
} else {
"Notification listener access is off, so no notification events can be forwarded yet."
},
style = mobileCallout,
)
},
trailingContent = {
Switch(
checked = notificationForwardingEnabled,
onCheckedChange = viewModel::setNotificationForwardingEnabled,
enabled = notificationListenerEnabled,
)
},
)
}
item {
Text(
if (notificationListenerEnabled) {
"Forwarding is available when enabled below."
} else {
"Forwarding controls stay disabled until Notification Listener Access is enabled in system Settings."
},
style = mobileCallout,
color = mobileTextSecondary,
)
}
item {
Column(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Package Filter: Allowlist", style = mobileHeadline) },
supportingContent = {
Text("Only listed package IDs are forwarded.", style = mobileCallout)
},
trailingContent = {
RadioButton(
selected = notificationForwardingMode == NotificationPackageFilterMode.Allowlist,
onClick = {
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Allowlist)
},
enabled = notificationForwardingAvailable,
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Package Filter: Blocklist", style = mobileHeadline) },
supportingContent = {
Text("All packages except listed IDs are forwarded.", style = mobileCallout)
},
trailingContent = {
RadioButton(
selected = notificationForwardingMode == NotificationPackageFilterMode.Blocklist,
onClick = {
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Blocklist)
},
enabled = notificationForwardingAvailable,
)
},
)
}
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { notificationPickerExpanded = !notificationPickerExpanded },
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (notificationPickerExpanded) "Close App Picker" else "Open App Picker",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
}
}
item {
Text(
selectedPackagesSummary,
style = mobileCallout,
color = mobileTextSecondary,
)
}
if (notificationPickerExpanded) {
item {
OutlinedTextField(
value = notificationAppSearch,
onValueChange = { notificationAppSearch = it },
label = {
Text("Search apps", style = mobileCaption1, color = mobileTextSecondary)
},
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text("Show System Apps", style = mobileHeadline) },
supportingContent = {
Text("Include Android/system packages in results.", style = mobileCallout)
},
trailingContent = {
Switch(
checked = notificationShowSystemApps,
onCheckedChange = { notificationShowSystemApps = it },
enabled = notificationForwardingAvailable,
)
},
)
}
items(filteredNotificationApps, key = { it.packageName }) { app ->
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text(app.label, style = mobileHeadline) },
supportingContent = { Text(app.packageName, style = mobileCallout) },
trailingContent = {
Switch(
checked = notificationForwardingPackages.contains(app.packageName),
onCheckedChange = { checked ->
val next = notificationForwardingPackages.toMutableSet()
if (checked) {
next.add(app.packageName)
} else {
next.remove(app.packageName)
}
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
},
enabled = notificationForwardingAvailable,
)
},
)
}
}
item {
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text("Quiet Hours", style = mobileHeadline) },
supportingContent = {
Text("Suppress forwarding during a local time window.", style = mobileCallout)
},
trailingContent = {
Switch(
checked = notificationForwardingQuietHoursEnabled,
onCheckedChange = {
if (!quietHoursCanEnable && it) return@Switch
viewModel.setNotificationForwardingQuietHours(
enabled = it,
start = notificationQuietStartDraft,
end = notificationQuietEndDraft,
)
},
enabled = if (notificationForwardingQuietHoursEnabled) notificationForwardingAvailable else quietHoursCanEnable,
)
},
)
}
item {
OutlinedTextField(
value = notificationQuietStartDraft,
onValueChange = { notificationQuietStartDraft = it },
label = { Text("Quiet Start (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
isError = notificationForwardingAvailable && normalizedQuietStartDraft == null,
supportingText = {
if (notificationForwardingAvailable && normalizedQuietStartDraft == null) {
Text("Use 24-hour HH:mm format, for example 22:00.", style = mobileCaption1, color = mobileDanger)
}
},
)
}
item {
OutlinedTextField(
value = notificationQuietEndDraft,
onValueChange = { notificationQuietEndDraft = it },
label = { Text("Quiet End (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
isError = notificationForwardingAvailable && normalizedQuietEndDraft == null,
supportingText = {
if (notificationForwardingAvailable && normalizedQuietEndDraft == null) {
Text("Use 24-hour HH:mm format, for example 07:00.", style = mobileCaption1, color = mobileDanger)
}
},
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
viewModel.setNotificationForwardingQuietHours(
enabled = notificationForwardingQuietHoursEnabled,
start = notificationQuietStartDraft,
end = notificationQuietEndDraft,
)
},
enabled = quietHoursSaveEnabled,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Quiet Hours", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item {
OutlinedTextField(
value = notificationRateDraft,
onValueChange = { notificationRateDraft = it.filter { c -> c.isDigit() } },
label = { Text("Max Events / Minute", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
val parsed = notificationRateDraft.toIntOrNull() ?: notificationForwardingMaxEventsPerMinute
viewModel.setNotificationForwardingMaxEventsPerMinute(parsed)
},
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Rate", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item {
OutlinedTextField(
value = notificationSessionKeyDraft,
onValueChange = { notificationSessionKeyDraft = it },
label = {
Text(
"Route Session Key (optional)",
style = mobileCaption1,
color = mobileTextSecondary,
)
},
placeholder = {
Text("Blank keeps notification events on this device's default notification route. Set a key only to pin forwarding into a different session.", style = mobileCaption1, color = mobileTextSecondary)
},
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
viewModel.setNotificationForwardingSessionKey(notificationSessionKeyDraft.trim().ifEmpty { null })
},
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Session Route", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item { HorizontalDivider(color = mobileBorder) }
// ── Data Access ──
item {
@@ -774,6 +1152,78 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
private fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
): List<InstalledApp> {
val packageManager = context.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launcherPackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull { it.activityInfo?.packageName?.trim()?.takeIf(String::isNotEmpty) }
.toMutableSet()
val recentNotificationPackages =
DeviceNotificationListenerService
.recentPackages(context)
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
val candidatePackages =
resolveNotificationCandidatePackages(
launcherPackages = launcherPackages,
recentPackages = recentNotificationPackages,
configuredPackages = configuredPackages,
appPackageName = context.packageName,
)
return candidatePackages
.asSequence()
.mapNotNull { packageName ->
runCatching {
val appInfo = packageManager.getApplicationInfo(packageName, 0)
val label = packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty()
InstalledApp(
label = if (label.isEmpty()) packageName else label,
packageName = packageName,
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
)
}.getOrNull()
}
.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
configuredPackages: Set<String>,
appPackageName: String,
): Set<String> {
val blockedPackage = appPackageName.trim()
return sequenceOf(
configuredPackages.asSequence(),
launcherPackages.asSequence(),
recentPackages.asSequence(),
)
.flatten()
.map { it.trim() }
.filter { it.isNotEmpty() && it != blockedPackage }
.toSet()
}
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
@@ -842,5 +1292,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}

View File

@@ -61,7 +61,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(mainSessionKey) {
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
}

View File

@@ -52,6 +52,7 @@ class MicCaptureManager(
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val transcriptIdleFlushMs = 1_600L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
@@ -87,8 +88,7 @@ class MicCaptureManager(
val isSending: StateFlow<Boolean> = _isSending
private val messageQueue = ArrayDeque<String>()
private val sessionSegments = mutableListOf<String>()
private var lastFinalSegment: String? = null
private var flushedPartialTranscript: String? = null
private var pendingRunId: String? = null
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
@@ -96,6 +96,7 @@ class MicCaptureManager(
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var drainJob: Job? = null
private var transcriptFlushJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
@@ -115,10 +116,9 @@ class MicCaptureManager(
stop()
// Capture any partial transcript that didn't get a final result from the recognizer
val partial = _liveTranscript.value?.trim().orEmpty()
if (partial.isNotEmpty() && sessionSegments.isEmpty()) {
sessionSegments.add(partial)
if (partial.isNotEmpty()) {
queueRecognizedMessage(partial)
}
flushSessionToQueue()
drainJob = null
_micCooldown.value = false
sendQueuedIfIdle()
@@ -132,6 +132,11 @@ class MicCaptureManager(
sendQueuedIfIdle()
return
}
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
pendingRunId = null
pendingAssistantEntryId = null
_isSending.value = false
if (messageQueue.isNotEmpty()) {
_statusText.value = queuedWaitingStatus()
}
@@ -210,6 +215,8 @@ class MicCaptureManager(
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
@@ -263,17 +270,10 @@ class MicCaptureManager(
}
}
private fun flushSessionToQueue() {
// Add sentence-ending punctuation between recognizer segments to avoid run-on text
val message = sessionSegments.joinToString(". ") { segment ->
val trimmed = segment.trimEnd()
if (trimmed.isNotEmpty() && trimmed.last() in ".!?,;:") trimmed else trimmed
}.trim().let { if (it.isNotEmpty() && it.last() !in ".!?") "$it." else it }
sessionSegments.clear()
private fun queueRecognizedMessage(text: String) {
val message = text.trim()
_liveTranscript.value = null
lastFinalSegment = null
if (message.isEmpty()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
@@ -282,6 +282,20 @@ class MicCaptureManager(
publishQueue()
}
private fun scheduleTranscriptFlush(expectedText: String) {
transcriptFlushJob?.cancel()
transcriptFlushJob =
scope.launch {
delay(transcriptIdleFlushMs)
if (!_micEnabled.value || _isSending.value) return@launch
val current = _liveTranscript.value?.trim().orEmpty()
if (current.isEmpty() || current != expectedText) return@launch
flushedPartialTranscript = current
queueRecognizedMessage(current)
sendQueuedIfIdle()
}
}
private fun publishQueue() {
_queuedMessages.value = messageQueue.toList()
}
@@ -436,19 +450,12 @@ class MicCaptureManager(
}
}
private fun onFinalTranscript(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
_liveTranscript.value = trimmed
if (lastFinalSegment == trimmed) return
lastFinalSegment = trimmed
sessionSegments.add(trimmed)
}
private fun disableMic(status: String) {
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_micEnabled.value = false
_isListening.value = false
_inputLevel.value = 0f
@@ -546,11 +553,18 @@ class MicCaptureManager(
}
override fun onResults(results: Bundle?) {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
onFinalTranscript(text)
// Don't auto-send on silence — accumulate transcript.
// Send happens when mic is toggled off (setMicEnabled(false)).
val trimmed = text.trim()
if (trimmed != flushedPartialTranscript) {
queueRecognizedMessage(trimmed)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
scheduleRestart()
}
@@ -558,7 +572,9 @@ class MicCaptureManager(
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
_liveTranscript.value = text.trim()
val trimmed = text.trim()
_liveTranscript.value = trimmed
scheduleTranscriptFlush(trimmed)
}
}

View File

@@ -7,7 +7,6 @@ import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -15,12 +14,12 @@ import android.os.SystemClock
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Base64
import android.util.Log
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.isCanonicalMainSessionKey
import java.io.File
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CancellationException
@@ -86,8 +85,6 @@ class TalkModeManager(
private var lastSpokenText: String? = null
private var lastInterruptedAtSeconds: Double? = null
private var currentVoiceId: String? = null
private var currentModelId: String? = null
// Interrupt-on-speech is disabled by default: starting a SpeechRecognizer during
// TTS creates an audio session conflict on some OEMs. Can be enabled via gateway talk config.
private var interruptOnSpeech: Boolean = false
@@ -104,8 +101,10 @@ class TalkModeManager(
private val playbackGeneration = AtomicLong(0L)
private var ttsJob: Job? = null
private val playerLock = Any()
private var player: MediaPlayer? = null
private val ttsLock = Any()
private var textToSpeech: TextToSpeech? = null
private var textToSpeechInit: CompletableDeferred<TextToSpeech>? = null
@Volatile private var currentUtteranceId: String? = null
@Volatile private var finalizeInFlight = false
private var listenWatchdogJob: Job? = null
@@ -131,7 +130,6 @@ class TalkModeManager(
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
@@ -340,6 +338,7 @@ class TalkModeManager(
recognizer?.destroy()
recognizer = null
}
shutdownTextToSpeech()
}
private fun startListeningInternal(markListening: Boolean) {
@@ -647,19 +646,6 @@ class TalkModeManager(
val cleaned = parsed.stripped.trim()
if (cleaned.isEmpty()) return
_lastAssistantText.value = cleaned
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
if (directive?.voiceId != null) {
if (directive.once != true) {
currentVoiceId = requestedVoice
}
}
if (directive?.modelId != null) {
if (directive.once != true) {
currentModelId = directive.modelId?.trim()?.takeIf { it.isNotEmpty() }
}
}
ensurePlaybackActive(playbackToken)
_statusText.value = "Speaking…"
@@ -670,147 +656,98 @@ class TalkModeManager(
try {
val ttsStarted = SystemClock.elapsedRealtime()
val speech = requestTalkSpeak(cleaned, directive)
playGatewaySpeech(speech, playbackToken)
Log.d(tag, "talk.speak ok durMs=${SystemClock.elapsedRealtime() - ttsStarted} provider=${speech.provider}")
speakWithSystemTts(cleaned, directive, playbackToken)
Log.d(tag, "system tts ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}")
} catch (err: Throwable) {
if (isPlaybackCancelled(err, playbackToken)) {
Log.d(tag, "assistant speech cancelled")
return
}
_statusText.value = "Speak failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "talk.speak failed: ${err.message ?: err::class.simpleName}")
Log.w(tag, "system tts failed: ${err.message ?: err::class.simpleName}")
} finally {
_isSpeaking.value = false
}
}
private data class GatewayTalkSpeech(
val audioBase64: String,
val provider: String,
val outputFormat: String?,
val mimeType: String?,
val fileExtension: String?,
)
private suspend fun requestTalkSpeak(text: String, directive: TalkDirective?): GatewayTalkSpeech {
val modelId =
directive?.modelId?.trim()?.takeIf { it.isNotEmpty() } ?: currentModelId?.trim()?.takeIf { it.isNotEmpty() }
val voiceId =
directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } ?: currentVoiceId?.trim()?.takeIf { it.isNotEmpty() }
val params =
buildJsonObject {
put("text", JsonPrimitive(text))
voiceId?.let { put("voiceId", JsonPrimitive(it)) }
modelId?.let { put("modelId", JsonPrimitive(it)) }
TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm)?.let {
put("speed", JsonPrimitive(it))
}
TalkModeRuntime.validatedStability(directive?.stability, modelId)?.let {
put("stability", JsonPrimitive(it))
}
TalkModeRuntime.validatedUnit(directive?.similarity)?.let {
put("similarity", JsonPrimitive(it))
}
TalkModeRuntime.validatedUnit(directive?.style)?.let {
put("style", JsonPrimitive(it))
}
directive?.speakerBoost?.let { put("speakerBoost", JsonPrimitive(it)) }
TalkModeRuntime.validatedSeed(directive?.seed)?.let { put("seed", JsonPrimitive(it)) }
TalkModeRuntime.validatedNormalize(directive?.normalize)?.let {
put("normalize", JsonPrimitive(it))
}
TalkModeRuntime.validatedLanguage(directive?.language)?.let {
put("language", JsonPrimitive(it))
}
directive?.outputFormat?.trim()?.takeIf { it.isNotEmpty() }?.let {
put("outputFormat", JsonPrimitive(it))
}
}
val res = session.request("talk.speak", params.toString())
val root = json.parseToJsonElement(res).asObjectOrNull() ?: error("talk.speak returned invalid JSON")
val audioBase64 = root["audioBase64"].asStringOrNull()?.trim().orEmpty()
val provider = root["provider"].asStringOrNull()?.trim().orEmpty()
if (audioBase64.isEmpty()) {
error("talk.speak missing audioBase64")
}
if (provider.isEmpty()) {
error("talk.speak missing provider")
}
return GatewayTalkSpeech(
audioBase64 = audioBase64,
provider = provider,
outputFormat = root["outputFormat"].asStringOrNull()?.trim(),
mimeType = root["mimeType"].asStringOrNull()?.trim(),
fileExtension = root["fileExtension"].asStringOrNull()?.trim(),
)
}
private suspend fun playGatewaySpeech(speech: GatewayTalkSpeech, playbackToken: Long) {
private suspend fun speakWithSystemTts(text: String, directive: TalkDirective?, playbackToken: Long) {
ensurePlaybackActive(playbackToken)
cleanupPlayer()
ensurePlaybackActive(playbackToken)
val audioBytes =
try {
Base64.decode(speech.audioBase64, Base64.DEFAULT)
} catch (err: IllegalArgumentException) {
throw IllegalStateException("talk.speak returned invalid audio", err)
val engine = ensureTextToSpeech()
val utteranceId = UUID.randomUUID().toString()
val finished = CompletableDeferred<Unit>()
withContext(Dispatchers.Main) {
ensurePlaybackActive(playbackToken)
synchronized(ttsLock) {
currentUtteranceId = utteranceId
engine.stop()
}
val suffix = resolveGatewayAudioSuffix(speech)
val tempFile =
withContext(Dispatchers.IO) { File.createTempFile("tts_", suffix, context.cacheDir) }
try {
withContext(Dispatchers.IO) { tempFile.writeBytes(audioBytes) }
val player = MediaPlayer()
synchronized(playerLock) {
this.player = player
val locale =
TalkModeRuntime.validatedLanguage(directive?.language)?.let { Locale.forLanguageTag(it) }
if (locale != null) {
val localeResult = engine.setLanguage(locale)
if (
localeResult == TextToSpeech.LANG_MISSING_DATA ||
localeResult == TextToSpeech.LANG_NOT_SUPPORTED
) {
throw IllegalStateException("Language unavailable on this device")
}
}
val finished = CompletableDeferred<Unit>()
player.setAudioAttributes(
engine.setSpeechRate((TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm) ?: 1.0).toFloat())
engine.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build(),
)
player.setOnCompletionListener { finished.complete(Unit) }
player.setOnErrorListener { _, what, extra ->
finished.completeExceptionally(IllegalStateException("MediaPlayer error what=$what extra=$extra"))
true
engine.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit
override fun onDone(utteranceId: String?) {
if (utteranceId == currentUtteranceId) {
finished.complete(Unit)
}
}
@Suppress("OVERRIDE_DEPRECATION")
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
if (utteranceId == currentUtteranceId) {
finished.completeExceptionally(IllegalStateException("TextToSpeech playback failed"))
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
if (utteranceId == currentUtteranceId) {
finished.completeExceptionally(IllegalStateException("TextToSpeech playback failed ($errorCode)"))
}
}
override fun onStop(utteranceId: String?, interrupted: Boolean) {
if (utteranceId == currentUtteranceId) {
finished.completeExceptionally(CancellationException("assistant speech cancelled"))
}
}
},
)
val result = engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
if (result != TextToSpeech.SUCCESS) {
throw IllegalStateException("TextToSpeech start failed")
}
player.setDataSource(tempFile.absolutePath)
withContext(Dispatchers.IO) { player.prepare() }
ensurePlaybackActive(playbackToken)
player.start()
}
try {
finished.await()
ensurePlaybackActive(playbackToken)
} finally {
try {
cleanupPlayer(player)
} catch (_: Throwable) {}
tempFile.delete()
synchronized(ttsLock) {
if (currentUtteranceId == utteranceId) {
currentUtteranceId = null
}
}
}
}
private fun resolveGatewayAudioSuffix(speech: GatewayTalkSpeech): String {
val extension = speech.fileExtension?.trim()
if (!extension.isNullOrEmpty()) {
return if (extension.startsWith(".")) extension else ".$extension"
}
val mimeType = speech.mimeType?.trim()?.lowercase()
if (mimeType == "audio/mpeg") return ".mp3"
if (mimeType == "audio/ogg") return ".ogg"
if (mimeType == "audio/wav") return ".wav"
if (mimeType == "audio/webm") return ".webm"
val outputFormat = speech.outputFormat?.trim()?.lowercase().orEmpty()
if (outputFormat == "mp3" || outputFormat.startsWith("mp3_") || outputFormat.endsWith("-mp3")) return ".mp3"
if (outputFormat == "opus" || outputFormat.startsWith("opus_")) return ".ogg"
if (outputFormat.endsWith("-wav")) return ".wav"
if (outputFormat.endsWith("-webm")) return ".webm"
return ".audio"
}
fun stopTts() {
stopSpeaking(resetInterrupt = true)
_isSpeaking.value = false
@@ -819,19 +756,14 @@ class TalkModeManager(
private fun stopSpeaking(resetInterrupt: Boolean = true) {
if (!_isSpeaking.value) {
cleanupPlayer()
stopTextToSpeechPlayback()
abandonAudioFocus()
return
}
if (resetInterrupt) {
val currentMs = synchronized(playerLock) {
try {
player?.currentPosition?.toDouble() ?: 0.0
} catch (_: IllegalStateException) { 0.0 }
}
lastInterruptedAtSeconds = currentMs / 1000.0
lastInterruptedAtSeconds = null
}
cleanupPlayer()
stopTextToSpeechPlayback()
_isSpeaking.value = false
abandonAudioFocus()
}
@@ -871,15 +803,79 @@ class TalkModeManager(
audioFocusRequest = null
}
private fun cleanupPlayer(expectedPlayer: MediaPlayer? = null) {
synchronized(playerLock) {
val p = player ?: return
if (expectedPlayer != null && p !== expectedPlayer) return
player = null
try {
p.stop()
} catch (_: IllegalStateException) {}
p.release()
private suspend fun ensureTextToSpeech(): TextToSpeech {
val existing = synchronized(ttsLock) { textToSpeech }
if (existing != null) {
return existing
}
val deferred: CompletableDeferred<TextToSpeech>
val created: Boolean
synchronized(ttsLock) {
val ready = textToSpeech
if (ready != null) {
deferred = CompletableDeferred<TextToSpeech>().also { it.complete(ready) }
created = false
} else {
val pending = textToSpeechInit
if (pending != null) {
deferred = pending
created = false
} else {
deferred = CompletableDeferred<TextToSpeech>()
textToSpeechInit = deferred
created = true
}
}
}
if (!created) {
return deferred.await()
}
withContext(Dispatchers.Main) {
synchronized(ttsLock) {
textToSpeech?.let {
textToSpeechInit = null
deferred.complete(it)
return@withContext
}
}
var engine: TextToSpeech? = null
engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
val initialized = engine ?: run {
deferred.completeExceptionally(IllegalStateException("TextToSpeech init failed"))
return@TextToSpeech
}
synchronized(ttsLock) {
textToSpeech = initialized
textToSpeechInit = null
}
deferred.complete(initialized)
} else {
synchronized(ttsLock) {
textToSpeechInit = null
}
engine?.shutdown()
deferred.completeExceptionally(IllegalStateException("TextToSpeech init failed ($status)"))
}
}
}
return deferred.await()
}
private fun stopTextToSpeechPlayback() {
synchronized(ttsLock) {
currentUtteranceId = null
textToSpeech?.stop()
}
}
private fun shutdownTextToSpeech() {
synchronized(ttsLock) {
currentUtteranceId = null
textToSpeech?.stop()
textToSpeech?.shutdown()
textToSpeech = null
textToSpeechInit = null
}
}
@@ -913,9 +909,6 @@ class TalkModeManager(
val res = session.request("talk.config", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = parsed.mainSessionKey
}
silenceWindowMs = parsed.silenceTimeoutMs
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
configLoaded = true
@@ -944,32 +937,6 @@ class TalkModeManager(
return null
}
fun validatedUnit(value: Double?): Double? {
if (value == null) return null
if (value < 0 || value > 1) return null
return value
}
fun validatedStability(value: Double?, modelId: String?): Double? {
if (value == null) return null
val normalized = modelId?.trim()?.lowercase()
if (normalized == "eleven_v3") {
return if (value == 0.0 || value == 0.5 || value == 1.0) value else null
}
return validatedUnit(value)
}
fun validatedSeed(value: Long?): Long? {
if (value == null) return null
if (value < 0 || value > 4294967295L) return null
return value
}
fun validatedNormalize(value: String?): String? {
val normalized = value?.trim()?.lowercase() ?: return null
return if (normalized in listOf("auto", "on", "off")) normalized else null
}
fun validatedLanguage(value: String?): String? {
val normalized = value?.trim()?.lowercase() ?: return null
if (normalized.length != 2) return null

View File

@@ -0,0 +1,58 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewayBootstrapAuthTest {
@Test
fun connectsOperatorSessionWhenBootstrapAuthExists() {
assertTrue(shouldConnectOperatorSession(token = "", bootstrapToken = "bootstrap-1", password = "", storedOperatorToken = ""))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
}
@Test
fun skipsOperatorSessionOnlyWhenNoSharedBootstrapOrStoredAuthExists() {
assertTrue(shouldConnectOperatorSession(token = "shared-token", bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = "shared-password", storedOperatorToken = null))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = null, password = null, storedOperatorToken = "stored-token"))
assertFalse(shouldConnectOperatorSession(token = null, bootstrapToken = "", password = null, storedOperatorToken = null))
}
@Test
fun resolveGatewayConnectAuth_prefersExplicitSetupAuthOverStoredPrefs() {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.setGatewayToken("stale-shared-token")
prefs.setGatewayBootstrapToken("")
prefs.setGatewayPassword("stale-password")
val runtime = NodeRuntime(app, prefs)
val auth =
runtime.resolveGatewayConnectAuth(
NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = "setup-bootstrap-token",
password = null,
),
)
assertNull(auth.token)
assertEquals("setup-bootstrap-token", auth.bootstrapToken)
assertNull(auth.password)
}
}

View File

@@ -0,0 +1,189 @@
package ai.openclaw.app
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NotificationForwardingPolicyTest {
@Test
fun parseLocalHourMinute_parsesValidValues() {
assertEquals(0, parseLocalHourMinute("00:00"))
assertEquals(23 * 60 + 59, parseLocalHourMinute("23:59"))
assertEquals(7 * 60 + 5, parseLocalHourMinute("07:05"))
}
@Test
fun normalizeLocalHourMinute_acceptsStrict24HourDrafts() {
assertEquals("00:00", normalizeLocalHourMinute("00:00"))
assertEquals("23:59", normalizeLocalHourMinute("23:59"))
assertEquals("07:05", normalizeLocalHourMinute("07:05"))
}
@Test
fun parseLocalHourMinute_rejectsInvalidValues() {
assertEquals(null, parseLocalHourMinute(""))
assertEquals(null, parseLocalHourMinute("24:00"))
assertEquals(null, parseLocalHourMinute("12:60"))
assertEquals(null, parseLocalHourMinute("abc"))
assertEquals(null, parseLocalHourMinute("7:05"))
assertEquals(null, parseLocalHourMinute("07:5"))
}
@Test
fun normalizeLocalHourMinute_rejectsNonCanonicalDrafts() {
assertEquals(null, normalizeLocalHourMinute(""))
assertEquals(null, normalizeLocalHourMinute("7:05"))
assertEquals(null, normalizeLocalHourMinute("07:5"))
assertEquals(null, normalizeLocalHourMinute("24:00"))
assertEquals(null, normalizeLocalHourMinute("12:60"))
}
@Test
fun allowsPackage_blocklistBlocksConfiguredPackages() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = setOf("com.blocked.app"),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertFalse(policy.allowsPackage("com.blocked.app"))
assertTrue(policy.allowsPackage("com.allowed.app"))
}
@Test
fun allowsPackage_allowlistOnlyAllowsConfiguredPackages() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Allowlist,
packages = setOf("com.allowed.app"),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertTrue(policy.allowsPackage("com.allowed.app"))
assertFalse(policy.allowsPackage("com.other.app"))
}
@Test
fun isWithinQuietHours_handlesWindowCrossingMidnight() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
val zone = ZoneId.of("UTC")
val at2330 =
LocalDateTime
.of(2024, 1, 6, 23, 30)
.atZone(zone)
.toInstant()
.toEpochMilli()
val at1200 =
LocalDateTime
.of(2024, 1, 6, 12, 0)
.atZone(zone)
.toInstant()
.toEpochMilli()
assertTrue(policy.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
assertFalse(policy.isWithinQuietHours(nowEpochMs = at1200, zoneId = zone))
}
@Test
fun isWithinQuietHours_sameStartEndMeansAlwaysQuiet() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "00:00",
quietEnd = "00:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertTrue(policy.isWithinQuietHours(nowEpochMs = 1_704_098_400_000L, zoneId = ZoneId.of("UTC")))
}
@Test
fun blocksEventsWhenDisabledOrQuietHoursOrRateLimited() {
val disabled =
NotificationForwardingPolicy(
enabled = false,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertFalse(disabled.enabled && disabled.allowsPackage("com.allowed.app"))
val quiet =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
val zone = ZoneId.of("UTC")
val at2330 =
LocalDateTime
.of(2024, 1, 6, 23, 30)
.atZone(zone)
.toInstant()
.toEpochMilli()
assertTrue(quiet.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = minute + 500L, maxEventsPerMinute = 1))
}
@Test
fun burstLimiter_blocksEventsAboveLimitInSameMinute() {
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 2))
assertTrue(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 2))
assertFalse(limiter.allow(nowEpochMs = minute + 2_000L, maxEventsPerMinute = 2))
}
@Test
fun burstLimiter_resetsOnNextMinuteWindow() {
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 1))
assertTrue(limiter.allow(nowEpochMs = minute + 60_000L, maxEventsPerMinute = 1))
}
}

View File

@@ -0,0 +1,133 @@
package ai.openclaw.app
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class SecurePrefsNotificationForwardingTest {
@Test
fun setNotificationForwardingQuietHours_rejectsInvalidDraftsWithoutMutatingStoredValues() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = false,
start = "22:00",
end = "07:00",
),
)
val originalStart = prefs.notificationForwardingQuietStart.value
val originalEnd = prefs.notificationForwardingQuietEnd.value
val originalEnabled = prefs.notificationForwardingQuietHoursEnabled.value
assertFalse(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "7:00",
end = "07:00",
),
)
assertEquals(originalStart, prefs.notificationForwardingQuietStart.value)
assertEquals(originalEnd, prefs.notificationForwardingQuietEnd.value)
assertEquals(originalEnabled, prefs.notificationForwardingQuietHoursEnabled.value)
}
@Test
fun setNotificationForwardingQuietHours_persistsValidDraftsAndEnabledState() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "22:30",
end = "06:45",
),
)
assertTrue(prefs.notificationForwardingQuietHoursEnabled.value)
assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
}
@Test
fun setNotificationForwardingQuietHours_disablesWithoutRevalidatingDrafts() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "22:30",
end = "06:45",
),
)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = false,
start = "7:00",
end = "06:45",
),
)
assertFalse(prefs.notificationForwardingQuietHoursEnabled.value)
assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
}
@Test
fun getNotificationForwardingPolicy_readsLatestQuietHoursImmediately() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "21:15",
end = "06:10",
),
)
val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
assertTrue(policy.quietHoursEnabled)
assertEquals("21:15", policy.quietStart)
assertEquals("06:10", policy.quietEnd)
}
@Test
fun notificationForwarding_defaultsDisabledForSaferPosture() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
assertFalse(prefs.notificationForwardingEnabled.value)
assertFalse(policy.enabled)
assertEquals(NotificationPackageFilterMode.Blocklist, policy.mode)
}
}

View File

@@ -0,0 +1,20 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionKeyTest {
@Test
fun buildNodeMainSessionKeyUsesStableDeviceScopedSuffix() {
val key = buildNodeMainSessionKey(deviceId = "1234567890abcdef", agentId = "ops")
assertEquals("agent:ops:node-1234567890ab", key)
}
@Test
fun resolveAgentIdFromMainSessionKeyParsesCanonicalAgentKey() {
assertEquals("ops", resolveAgentIdFromMainSessionKey("agent:ops:main"))
assertNull(resolveAgentIdFromMainSessionKey("global"))
}
}

View File

@@ -0,0 +1,32 @@
package ai.openclaw.app.chat
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatControllerSessionPolicyTest {
@Test
fun applyMainSessionKeyMovesCurrentSessionWhenStillOnDefault() {
val state =
applyMainSessionKey(
currentSessionKey = "main",
appliedMainSessionKey = "main",
nextMainSessionKey = "agent:ops:node-device",
)
assertEquals("agent:ops:node-device", state.currentSessionKey)
assertEquals("agent:ops:node-device", state.appliedMainSessionKey)
}
@Test
fun applyMainSessionKeyKeepsUserSelectedSession() {
val state =
applyMainSessionKey(
currentSessionKey = "custom",
appliedMainSessionKey = "agent:ops:node-old",
nextMainSessionKey = "agent:ops:node-new",
)
assertEquals("custom", state.currentSessionKey)
assertEquals("agent:ops:node-new", state.appliedMainSessionKey)
}
}

View File

@@ -173,15 +173,50 @@ class CallLogHandlerTest : NodeHandlerRobolectricTest() {
assertTrue(callLogObj.containsKey("number"))
assertTrue(callLogObj.containsKey("cachedName"))
}
@Test
fun handleCallLogSearch_clampsLimitAndOffsetBeforeSearch() {
val source = FakeCallLogDataSource(canRead = true)
val handler = CallLogHandler.forTesting(appContext(), source)
val result = handler.handleCallLogSearch("""{"limit":999,"offset":-5}""")
assertTrue(result.ok)
assertEquals(200, source.lastRequest?.limit)
assertEquals(0, source.lastRequest?.offset)
}
@Test
fun handleCallLogSearch_mapsSearchFailuresToUnavailable() {
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(
canRead = true,
failure = IllegalStateException("provider down"),
),
)
val result = handler.handleCallLogSearch(null)
assertFalse(result.ok)
assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code)
assertEquals("CALL_LOG_UNAVAILABLE: provider down", result.error?.message)
}
}
private class FakeCallLogDataSource(
private val canRead: Boolean,
private val searchResults: List<CallLogRecord> = emptyList(),
private val failure: Throwable? = null,
) : CallLogDataSource {
var lastRequest: CallLogSearchRequest? = null
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
lastRequest = request
failure?.let { throw it }
val startIndex = request.offset.coerceAtLeast(0)
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
return if (startIndex < searchResults.size) {

View File

@@ -1,10 +1,25 @@
package ai.openclaw.app.node
import ai.openclaw.app.LocationMode
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.VoiceWakeMode
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.gateway.GatewayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class ConnectionManagerTest {
@Test
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
@@ -73,4 +88,173 @@ class ConnectionManagerTest {
assertNull(on?.expectedFingerprint)
assertEquals(false, on?.allowTOFU)
}
@Test
fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_doesNotAdvertiseSmsWhenSearchIsImpossible() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesSmsCapabilityWhenReadSmsIsAvailable() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = true,
smsSearchPossible = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesSmsSendWithoutSearchWhenOnlySendIsAvailable() {
val options =
newManager(
sendSmsAvailable = true,
readSmsAvailable = false,
smsSearchPossible = false,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesAvailableNonSmsCommandsAndCapabilities() {
val options =
newManager(
cameraEnabled = true,
locationMode = LocationMode.WhileUsing,
voiceWakeMode = VoiceWakeMode.Always,
motionActivityAvailable = true,
callLogAvailable = true,
hasRecordAudioPermission = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawCameraCommand.List.rawValue))
assertTrue(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Camera.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Location.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.CallLog.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
val options =
newManager(
voiceWakeMode = VoiceWakeMode.Always,
hasRecordAudioPermission = false,
).buildNodeConnectOptions()
assertFalse(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsUnavailableCameraLocationAndCallLogSurfaces() {
val options =
newManager(
cameraEnabled = false,
locationMode = LocationMode.Off,
callLogAvailable = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawCameraCommand.List.rawValue))
assertFalse(options.commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertFalse(options.commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertFalse(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
assertFalse(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Camera.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Location.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.CallLog.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesOnlyAvailableMotionCommand() {
val options =
newManager(
motionActivityAvailable = false,
motionPedometerAvailable = true,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsMotionSurfaceWhenMotionApisUnavailable() {
val options =
newManager(
motionActivityAvailable = false,
motionPedometerAvailable = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Motion.rawValue))
}
private fun newManager(
cameraEnabled: Boolean = false,
locationMode: LocationMode = LocationMode.Off,
voiceWakeMode: VoiceWakeMode = VoiceWakeMode.Off,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsSearchPossible: Boolean = false,
callLogAvailable: Boolean = false,
hasRecordAudioPermission: Boolean = false,
): ConnectionManager {
val context = RuntimeEnvironment.getApplication()
val prefs =
SecurePrefs(
context,
securePrefsOverride = context.getSharedPreferences("connection-manager-test", android.content.Context.MODE_PRIVATE),
)
return ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled },
locationMode = { locationMode },
voiceWakeMode = { voiceWakeMode },
motionActivityAvailable = { motionActivityAvailable },
motionPedometerAvailable = { motionPedometerAvailable },
sendSmsAvailable = { sendSmsAvailable },
readSmsAvailable = { readSmsAvailable },
smsSearchPossible = { smsSearchPossible },
callLogAvailable = { callLogAvailable },
hasRecordAudioPermission = { hasRecordAudioPermission },
manualTls = { false },
)
}
}

View File

@@ -101,9 +101,131 @@ class DeviceHandlerTest {
val status = state.getValue("status").jsonPrimitive.content
assertTrue(status == "granted" || status == "denied")
state.getValue("promptable").jsonPrimitive.boolean
if (key == "sms") {
val capabilities = state.getValue("capabilities").jsonObject
for (capabilityKey in listOf("send", "read")) {
val capability = capabilities.getValue(capabilityKey).jsonObject
val capabilityStatus = capability.getValue("status").jsonPrimitive.content
assertTrue(capabilityStatus == "granted" || capabilityStatus == "denied")
capability.getValue("promptable").jsonPrimitive.boolean
}
}
}
}
@Test
fun smsTopLevelStatusTreatsSendOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelStatusTreatsReadOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelStatusTreatsNoSmsGrantAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelStatusTreatsDisabledSmsAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = false,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelStatusTreatsMissingTelephonyAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = false,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelPromptableStaysTrueUntilBothSmsPermissionsAreGranted() {
assertTrue(
DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelPromptableIsFalseWhenSmsCannotExist() {
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = false,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = false,
),
)
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = false,
smsSendGranted = false,
smsReadGranted = false,
),
)
}
@Test
fun handleDevicePermissions_marksCallLogUnpromptableWhenFeatureDisabled() {
val handler = DeviceHandler(appContext(), callLogEnabled = false)
val result = handler.handleDevicePermissions(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val callLog = payload.getValue("permissions").jsonObject.getValue("callLog").jsonObject
assertEquals("denied", callLog.getValue("status").jsonPrimitive.content)
assertTrue(!callLog.getValue("promptable").jsonPrimitive.boolean)
}
@Test
fun handleDeviceHealth_returnsExpectedShape() {
val handler = DeviceHandler(appContext())

View File

@@ -0,0 +1,119 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.NotificationForwardingPolicy
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.isWithinQuietHours
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class DeviceNotificationListenerServiceTest {
@Test
fun recentPackages_migratesLegacyPreferenceKey() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString("notifications.recentPackages", "com.example.one, com.example.two")
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.one", "com.example.two"), packages)
assertEquals(
"com.example.one, com.example.two",
prefs.getString("notifications.forwarding.recentPackages", null),
)
assertFalse(prefs.contains("notifications.recentPackages"))
}
@Test
fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString("notifications.forwarding.recentPackages", "com.example.new")
.putString("notifications.recentPackages", "com.example.legacy")
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.new"), packages)
assertNull(prefs.getString("notifications.recentPackages", null))
}
@Test
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString(
"notifications.forwarding.recentPackages",
" com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
)
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(
listOf("com.example.recent", "com.example.other", "com.example.third"),
packages,
)
}
@Test
fun quietHoursAndRateLimitingUseWallClockTimeNotNotificationPostTime() {
val zone = java.time.ZoneId.systemDefault()
val now = java.time.ZonedDateTime.now(zone)
val quietStart = now.minusMinutes(5).toLocalTime().withSecond(0).withNano(0)
val quietEnd = now.plusMinutes(5).toLocalTime().withSecond(0).withNano(0)
val stalePostTime =
now
.minusHours(2)
.withMinute(0)
.withSecond(0)
.withNano(0)
.toInstant()
.toEpochMilli()
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "%02d:%02d".format(quietStart.hour, quietStart.minute),
quietEnd = "%02d:%02d".format(quietEnd.hour, quietEnd.minute),
maxEventsPerMinute = 1,
sessionKey = null,
)
assertFalse(policy.isWithinQuietHours(nowEpochMs = stalePostTime, zoneId = zone))
assertTrue(policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis(), zoneId = zone))
val limiter = NotificationBurstLimiter()
assertTrue(limiter.allow(nowEpochMs = stalePostTime, maxEventsPerMinute = 1))
assertTrue(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
}
@Test
fun burstLimiter_capsAnyForwardedNotificationEvent() {
val limiter = NotificationBurstLimiter()
val nowEpochMs = System.currentTimeMillis()
assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
assertFalse(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
}
}

View File

@@ -12,6 +12,9 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -86,6 +89,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
@@ -113,6 +117,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@@ -132,6 +137,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = false,
callLogAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
@@ -148,17 +154,22 @@ class InvokeCommandRegistryTest {
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
defaultFlags(readSmsAvailable = true, smsSearchPossible = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(smsSearchPossible = true),
)
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(requestableSearchCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
@@ -171,9 +182,14 @@ class InvokeCommandRegistryTest {
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(smsSearchPossible = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(requestableSearchCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
@Test
@@ -190,11 +206,37 @@ class InvokeCommandRegistryTest {
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
}
@Test
fun advertisedCapabilities_includesVoiceWakeWithoutAdvertisingCommands() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(voiceWakeEnabled = true))
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(voiceWakeEnabled = true))
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertFalse(commands.any { it.contains("voice", ignoreCase = true) })
}
@Test
fun find_returnsForegroundMetadataForCameraCommands() {
val list = InvokeCommandRegistry.find(OpenClawCameraCommand.List.rawValue)
val location = InvokeCommandRegistry.find(OpenClawLocationCommand.Get.rawValue)
assertNotNull(list)
assertEquals(true, list?.requiresForeground)
assertNotNull(location)
assertEquals(false, location?.requiresForeground)
}
@Test
fun find_returnsNullForUnknownCommand() {
assertNull(InvokeCommandRegistry.find("not.real"))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsSearchPossible: Boolean = false,
callLogAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
@@ -206,6 +248,7 @@ class InvokeCommandRegistryTest {
locationEnabled = locationEnabled,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
smsSearchPossible = smsSearchPossible,
callLogAvailable = callLogAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,

View File

@@ -0,0 +1,368 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import android.content.Context
import android.content.pm.PackageManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class InvokeDispatcherTest {
@Test
fun classifySmsSearchAvailability_returnsAvailable_whenReadSmsIsAvailable() {
assertEquals(
SmsSearchAvailabilityReason.Available,
classifySmsSearchAvailability(
readSmsAvailable = true,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenSmsFeatureDisabled() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenTelephonyUnavailable() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = false,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsPermissionRequired_whenOnlyReadSmsPermissionIsMissing() {
assertEquals(
SmsSearchAvailabilityReason.PermissionRequired,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsNull_whenReadSmsPermissionIsRequestable() {
assertNull(
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsUnavailable_whenSmsSearchIsImpossible() {
val result =
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
)
assertEquals("SMS_UNAVAILABLE", result?.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result?.error?.message)
}
@Test
fun handleInvoke_allowsRequestableSmsSearchToReachHandler() =
runTest {
val result =
newDispatcher(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json")
assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code)
assertEquals("grant READ_SMS permission", result.error?.message)
}
@Test
fun handleInvoke_blocksSmsSearchWhenFeatureIsUnavailable() =
runTest {
val result =
newDispatcher(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json")
assertEquals("SMS_UNAVAILABLE", result.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message)
}
@Test
fun handleInvoke_allowsAvailableSmsSendToReachHandler() =
runTest {
val result =
newDispatcher(
sendSmsAvailable = true,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""")
assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code)
assertEquals("grant SMS permission", result.error?.message)
}
@Test
fun handleInvoke_blocksSmsSendWhenUnavailable() =
runTest {
val result =
newDispatcher(
sendSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""")
assertEquals("SMS_UNAVAILABLE", result.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message)
}
@Test
fun handleInvoke_blocksCameraCommandsWhenCameraDisabled() =
runTest {
val result = newDispatcher(cameraEnabled = false).handleInvoke(OpenClawCameraCommand.List.rawValue, null)
assertEquals("CAMERA_DISABLED", result.error?.code)
assertEquals("CAMERA_DISABLED: enable Camera in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksLocationCommandWhenLocationDisabled() =
runTest {
val result = newDispatcher(locationEnabled = false).handleInvoke(OpenClawLocationCommand.Get.rawValue, null)
assertEquals("LOCATION_DISABLED", result.error?.code)
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
runTest {
val result =
newDispatcher(motionActivityAvailable = false)
.handleInvoke(OpenClawMotionCommand.Activity.rawValue, null)
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertEquals("MOTION_UNAVAILABLE: accelerometer not available", result.error?.message)
}
@Test
fun handleInvoke_blocksMotionPedometerWhenUnavailable() =
runTest {
val result =
newDispatcher(motionPedometerAvailable = false)
.handleInvoke(OpenClawMotionCommand.Pedometer.rawValue, null)
assertEquals("PEDOMETER_UNAVAILABLE", result.error?.code)
assertEquals("PEDOMETER_UNAVAILABLE: step counter not available", result.error?.message)
}
@Test
fun handleInvoke_blocksCallLogWhenUnavailable() =
runTest {
val result =
newDispatcher(callLogAvailable = false).handleInvoke(OpenClawCallLogCommand.Search.rawValue, null)
assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code)
assertEquals("CALL_LOG_UNAVAILABLE: call log not available on this build", result.error?.message)
}
@Test
fun handleInvoke_treatsDebugCommandsAsUnknownOutsideDebugBuilds() =
runTest {
val result = newDispatcher(debugBuild = false).handleInvoke("debug.logs", null)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals("INVALID_REQUEST: unknown command", result.error?.message)
}
private fun newDispatcher(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsFeatureEnabled: Boolean = true,
smsTelephonyAvailable: Boolean = true,
callLogAvailable: Boolean = false,
debugBuild: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
): InvokeDispatcher {
val appContext = RuntimeEnvironment.getApplication()
shadowOf(appContext.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, smsTelephonyAvailable)
val canvas = CanvasController()
return InvokeDispatcher(
canvas = canvas,
cameraHandler = newCameraHandler(appContext),
locationHandler =
LocationHandler.forTesting(
appContext = appContext,
dataSource = InvokeDispatcherFakeLocationDataSource(),
),
deviceHandler = DeviceHandler(appContext),
notificationsHandler =
NotificationsHandler.forTesting(
appContext = appContext,
stateProvider = InvokeDispatcherFakeNotificationsStateProvider(),
),
systemHandler = SystemHandler.forTesting(InvokeDispatcherFakeSystemNotificationPoster()),
photosHandler = PhotosHandler.forTesting(appContext, InvokeDispatcherFakePhotosDataSource()),
contactsHandler = ContactsHandler.forTesting(appContext, InvokeDispatcherFakeContactsDataSource()),
calendarHandler = CalendarHandler.forTesting(appContext, InvokeDispatcherFakeCalendarDataSource()),
motionHandler = MotionHandler.forTesting(appContext, InvokeDispatcherFakeMotionDataSource()),
smsHandler = SmsHandler(SmsManager(appContext)),
a2uiHandler =
A2UIHandler(
canvas = canvas,
json = Json { ignoreUnknownKeys = true },
getNodeCanvasHostUrl = { null },
getOperatorCanvasHostUrl = { null },
),
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
isForeground = { true },
cameraEnabled = { cameraEnabled },
locationEnabled = { locationEnabled },
sendSmsAvailable = { sendSmsAvailable },
readSmsAvailable = { readSmsAvailable },
smsFeatureEnabled = { smsFeatureEnabled },
smsTelephonyAvailable = { smsTelephonyAvailable },
callLogAvailable = { callLogAvailable },
debugBuild = { debugBuild },
refreshNodeCanvasCapability = { false },
onCanvasA2uiPush = {},
onCanvasA2uiReset = {},
motionActivityAvailable = { motionActivityAvailable },
motionPedometerAvailable = { motionPedometerAvailable },
)
}
private fun newCameraHandler(appContext: Context): CameraHandler {
return CameraHandler(
appContext = appContext,
camera = CameraCaptureManager(appContext),
externalAudioCaptureActive = MutableStateFlow(false),
showCameraHud = { _, _, _ -> },
triggerCameraFlash = {},
invokeErrorFromThrowable = { err -> "UNAVAILABLE" to (err.message ?: "camera failed") },
)
}
}
private class InvokeDispatcherFakeLocationDataSource : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean = false
override fun hasCoarsePermission(context: Context): Boolean = false
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeNotificationsStateProvider : NotificationsStateProvider {
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
return DeviceNotificationSnapshot(enabled = false, connected = false, notifications = emptyList())
}
override fun requestServiceRebind(context: Context) = Unit
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
return NotificationActionResult(ok = true, code = null, message = null)
}
}
private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationPoster {
override fun isAuthorized(): Boolean = true
override fun post(request: SystemNotifyRequest) = Unit
}
private class InvokeDispatcherFakePhotosDataSource : PhotosDataSource {
override fun hasPermission(context: Context): Boolean = true
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> = emptyList()
}
private class InvokeDispatcherFakeContactsDataSource : ContactsDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun hasWritePermission(context: Context): Boolean = true
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> = emptyList()
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun hasWritePermission(context: Context): Boolean = true
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = emptyList()
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeMotionDataSource : MotionDataSource {
override fun isActivityAvailable(context: Context): Boolean = false
override fun isPedometerAvailable(context: Context): Boolean = false
override fun hasPermission(context: Context): Boolean = true
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
error("unused in InvokeDispatcherTest")
}
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> = emptyList()
}

View File

@@ -1,7 +1,9 @@
package ai.openclaw.app.node
import android.content.Context
import android.location.LocationManager
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -65,12 +67,110 @@ class LocationHandlerTest : NodeHandlerRobolectricTest() {
assertTrue(granted.hasFineLocationPermission())
assertFalse(granted.hasCoarseLocationPermission())
}
@Test
fun handleLocationGet_usesPreciseGpsFirstWhenFinePermissionAndPreciseEnabled() =
runTest {
val source =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
payload = LocationCaptureManager.Payload("""{"ok":true}"""),
)
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = source,
locationPreciseEnabled = { true },
)
val result = handler.handleLocationGet("""{"desiredAccuracy":"precise","maxAgeMs":1234,"timeoutMs":2000}""")
assertTrue(result.ok)
assertEquals(listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER), source.lastDesiredProviders)
assertEquals(1234L, source.lastMaxAgeMs)
assertEquals(2000L, source.lastTimeoutMs)
assertTrue(source.lastIsPrecise)
}
@Test
fun handleLocationGet_fallsBackToBalancedWhenPreciseUnavailable() =
runTest {
val source =
FakeLocationDataSource(
fineGranted = false,
coarseGranted = true,
payload = LocationCaptureManager.Payload("""{"ok":true}"""),
)
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = source,
locationPreciseEnabled = { true },
)
val result = handler.handleLocationGet("""{"desiredAccuracy":"precise"}""")
assertTrue(result.ok)
assertEquals(listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER), source.lastDesiredProviders)
assertFalse(source.lastIsPrecise)
}
@Test
fun handleLocationGet_mapsTimeoutToLocationTimeout() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
timeout = true,
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_TIMEOUT", result.error?.code)
assertEquals("LOCATION_TIMEOUT: no fix in time", result.error?.message)
}
@Test
fun handleLocationGet_mapsOtherFailuresToLocationUnavailable() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
failure = IllegalStateException("gps offline"),
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_UNAVAILABLE", result.error?.code)
assertEquals("gps offline", result.error?.message)
}
}
private class FakeLocationDataSource(
private val fineGranted: Boolean,
private val coarseGranted: Boolean,
private val payload: LocationCaptureManager.Payload? = null,
private val failure: Throwable? = null,
private val timeout: Boolean = false,
) : LocationDataSource {
var lastDesiredProviders: List<String> = emptyList()
var lastMaxAgeMs: Long? = null
var lastTimeoutMs: Long? = null
var lastIsPrecise: Boolean = false
override fun hasFinePermission(context: Context): Boolean = fineGranted
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
@@ -81,8 +181,16 @@ private class FakeLocationDataSource(
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
throw IllegalStateException(
"LocationHandlerTest: fetchLocation must not run in this scenario",
)
lastDesiredProviders = desiredProviders
lastMaxAgeMs = maxAgeMs
lastTimeoutMs = timeoutMs
lastIsPrecise = isPrecise
if (timeout) {
kotlinx.coroutines.withTimeout(1) {
kotlinx.coroutines.delay(5)
}
}
failure?.let { throw it }
return payload ?: LocationCaptureManager.Payload(Json.encodeToString(mapOf("ok" to true)))
}
}

View File

@@ -140,6 +140,46 @@ class NotificationsHandlerTest {
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_rejectsMissingKey() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n3")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"action":"open"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_rejectsInvalidAction() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n3")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n3","action":"archive"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_propagatesProviderError() =
runTest {
@@ -167,6 +207,29 @@ class NotificationsHandlerTest {
assertEquals(1, provider.actionRequests)
}
@Test
fun notificationsActions_fallsBackWhenProviderOmitsErrorDetails() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n4")),
),
).also {
it.actionResult = NotificationActionResult(ok = false)
}
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
assertFalse(result.ok)
assertEquals("UNAVAILABLE", result.error?.code)
assertEquals("notification action failed", result.error?.message)
assertEquals(1, provider.actionRequests)
}
@Test
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
runTest {

View File

@@ -1,15 +1,39 @@
package ai.openclaw.app.node
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
private fun smsMessage(
id: Long,
date: Long,
status: Int = 0,
body: String? = "msg-$id",
transportType: String? = null,
): SmsManager.SmsMessage =
SmsManager.SmsMessage(
id = id,
threadId = 1L,
address = "+15551234567",
person = null,
date = date,
dateSent = date,
read = true,
type = 1,
body = body,
status = status,
transportType = transportType,
)
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
@@ -61,6 +85,73 @@ class SmsManagerTest {
assertEquals("Hello", ok.params.message)
}
@Test
fun parseQueryParamsDefaultsWhenPayloadEmpty() {
val result = SmsManager.parseQueryParams(null, json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(25, ok.params.limit)
assertEquals(0, ok.params.offset)
assertEquals(null, ok.params.startTime)
assertEquals(null, ok.params.endTime)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsInvertedTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":200,\"endTime\":100}", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: startTime must be less than or equal to endTime", error.error)
}
@Test
fun parseQueryParamsClampsLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":999,\"offset\":-5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsParsesAllSupportedFields() {
val result = SmsManager.parseQueryParams(
"""
{
"startTime": 100,
"endTime": 200,
"contactName": " Leah ",
"phoneNumber": " +1555 ",
"keyword": " ping ",
"type": 1,
"isRead": true,
"limit": 10,
"offset": 2
}
""".trimIndent(),
json,
)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(100L, ok.params.startTime)
assertEquals(200L, ok.params.endTime)
assertEquals("Leah", ok.params.contactName)
assertEquals("+1555", ok.params.phoneNumber)
assertEquals("ping", ok.params.keyword)
assertEquals(1, ok.params.type)
assertEquals(true, ok.params.isRead)
assertEquals(10, ok.params.limit)
assertEquals(2, ok.params.offset)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
@@ -75,6 +166,69 @@ class SmsManagerTest {
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesCountAndMessages() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(
SmsManager.SmsMessage(
id = 1L,
threadId = 2L,
address = "+1555",
person = null,
date = 123L,
dateSent = 124L,
read = true,
type = 1,
body = "hello",
status = 0,
)
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(1, parsed["count"]?.jsonPrimitive?.content?.toInt())
val messages = parsed["messages"]?.jsonArray
assertEquals(1, messages?.size)
assertEquals("hello", messages?.get(0)?.jsonObject?.get("body")?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesErrorOnFailure() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: nope",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(0, parsed["count"]?.jsonPrimitive?.content?.toInt())
assertEquals("SMS_QUERY_FAILED: nope", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesMmsMetadataWhenProvided() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(smsMessage(id = 1L, date = 1000L)),
queryMetadata =
SmsManager.QueryMetadata(
mmsRequested = true,
mmsEligible = true,
mmsAttempted = true,
mmsIncluded = false,
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["mmsRequested"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsEligible"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsAttempted"]?.jsonPrimitive?.content)
assertEquals("false", parsed["mmsIncluded"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
@@ -98,14 +252,6 @@ class SmsManagerTest {
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsNonObjectJson() {
val result = SmsManager.parseQueryParams("[]", json)
@@ -179,4 +325,749 @@ class SmsManagerTest {
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
@Test
fun parseQueryParamsIncludeMmsDefaultsFalse() {
val result = SmsManager.parseQueryParams("{}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertFalse(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesIncludeMmsTrue() {
val result = SmsManager.parseQueryParams("{\"includeMms\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesConversationReviewTrue() {
val result = SmsManager.parseQueryParams("{\"conversationReview\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.conversationReview)
}
@Test
fun toByPhoneLookupNumberStripsFormattingToDigits() {
assertEquals("12107588120", SmsManager.toByPhoneLookupNumber("+1 (210) 758-8120"))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForFormattingOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull("() - "))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForPlusOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull(" + "))
}
@Test
fun normalizePhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.normalizePhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsFormattingOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" () - "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPlusOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" + "))
}
@Test
fun sanitizeContactPhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.sanitizeContactPhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPercentWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1%2"))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsUnderscoreWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1_2"))
}
@Test
fun shouldPromptForContactNameSearchPermissionTrueForContactNameOnlyWithoutContactsAccess() {
assertTrue(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenExplicitPhoneFallbackExists() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = "+15551234567",
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenContactsAlreadyGranted() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = true,
),
)
}
@Test
fun escapeSqlLikeLiteralEscapesPercentUnderscoreAndBackslash() {
assertEquals("\\%a\\_b\\\\c", SmsManager.escapeSqlLikeLiteral("%a_b\\c"))
}
@Test
fun escapeSqlLikeLiteralLeavesOrdinaryTextUnchanged() {
assertEquals("Leah", SmsManager.escapeSqlLikeLiteral("Leah"))
}
@Test
fun buildContactNameLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"display_name LIKE ? ESCAPE '\\'",
SmsManager.buildContactNameLikeSelection(),
)
}
@Test
fun buildContactNameLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildContactNameLikeArg("%a_b\\c"))
}
@Test
fun buildKeywordLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"body LIKE ? ESCAPE '\\'",
SmsManager.buildKeywordLikeSelection(),
)
}
@Test
fun buildKeywordLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildKeywordLikeArg("%a_b\\c"))
}
@Test
fun buildMixedByPhoneProjectionMatchesExpectedStatusAwareShape() {
assertArrayEquals(
arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
),
SmsManager.buildMixedByPhoneProjection(),
)
}
@Test
fun compareByPhoneCandidateOrderUsesDateThenIdDescending() {
val newer = smsMessage(id = 1L, date = 2000L)
val older = smsMessage(id = 2L, date = 1000L)
val sameDateHigherId = smsMessage(id = 9L, date = 1500L)
val sameDateLowerId = smsMessage(id = 3L, date = 1500L)
assertTrue(SmsManager.compareByPhoneCandidateOrder(newer, older) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateHigherId, sameDateLowerId) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateLowerId, sameDateHigherId) > 0)
}
@Test
fun upsertTopDateCandidatesKeepsDescendingOrderAndBounds() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 2
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1700L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 1500L), max)
assertEquals(listOf(2L, 1L), candidates.map { it.second.id })
assertEquals(listOf(2000L, 1700L), candidates.map { it.second.date })
}
@Test
fun upsertTopDateCandidatesSupportsDefaultMixedPathBoundedWindow() {
val params = SmsManager.QueryParams(limit = 3, offset = 2, includeMms = true, phoneNumber = "+15551234567")
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = params.offset + params.limit
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 3000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:4", smsMessage(id = 4L, date = 4000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:5", smsMessage(id = 5L, date = 5000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:6", smsMessage(id = 6L, date = 6000L), max)
assertEquals(5, candidates.size)
assertEquals(listOf(6L, 5L, 4L, 3L, 2L), candidates.map { it.second.id })
assertEquals(listOf(4000L, 3000L, 2000L), SmsManager.pageByPhoneCandidates(candidates.map { it.second }, params).map { it.date })
}
@Test
fun upsertTopDateCandidatesDedupesBySourceAwareIdentityAndKeepsBestOrdering() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 5
SmsManager.upsertTopDateCandidates(candidates, "sms:1987", smsMessage(id = 1987L, date = 1773950752506L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1985", smsMessage(id = 1985L, date = 1773872989602L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1981", smsMessage(id = 1981L, date = 1773790733566L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1976", smsMessage(id = 1976L, date = 1773784153770L), max)
// same source-aware identity should replace, not duplicate
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
// different source-aware identity with same raw id must be preserved
SmsManager.upsertTopDateCandidates(candidates, "mms:1986", smsMessage(id = 1986L, date = 1773899354038L), max)
assertEquals(5, candidates.size)
assertEquals(2, candidates.count { it.second.id == 1986L })
assertEquals(listOf("sms:1987", "sms:1986", "mms:1986", "sms:1985", "sms:1981"), candidates.map { it.first })
}
@Test
fun materializeByPhoneCandidateDedupesBySourceAwareIdentity() {
val candidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 1000L))
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 2000L))
SmsManager.materializeByPhoneCandidate(candidates, "mms:1", smsMessage(id = 1L, date = 1500L))
assertEquals(2, candidates.size)
assertEquals(2000L, candidates["sms:1"]?.date)
assertEquals(1500L, candidates["mms:1"]?.date)
}
@Test
fun collectMixedByPhoneCandidateUsesBoundedCollectorWhenReviewModeDisabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = false,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = false,
)
assertEquals(listOf(2L), topCandidates.map { it.second.id })
assertTrue(materializedCandidates.isEmpty())
}
@Test
fun collectMixedByPhoneCandidateMaterializesFullSetWhenReviewModeEnabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = true,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = true,
)
assertTrue(topCandidates.isEmpty())
assertEquals(listOf(1L, 2L), materializedCandidates.values.map { it.id })
}
@Test
fun pageMixedByPhoneCandidatesLetsReviewModeSurfaceOlderRowsBeyondBoundedDefaultWindow() {
val params =
SmsManager.QueryParams(
limit = 2,
offset = 2,
includeMms = true,
phoneNumber = "+15551234567",
conversationReview = true,
)
val topCandidates = listOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
)
val materializedCandidates =
linkedMapOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
"mms:6" to smsMessage(id = 6L, date = 6000L, transportType = "mms"),
)
val defaultPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params.copy(conversationReview = false),
reviewMode = false,
)
val reviewPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = true,
)
assertEquals(listOf(7L), defaultPage.map { it.id })
assertEquals(listOf(7L, 6L), reviewPage.map { it.id })
assertEquals(4, materializedCandidates.size)
}
@Test
fun pageByPhoneCandidatesHonorsDeepOffsetAfterStableSort() {
val params = SmsManager.QueryParams(limit = 5, offset = 5, includeMms = true)
val candidates = listOf(
smsMessage(id = 1399L, date = 1741112335720L),
smsMessage(id = 1976L, date = 1773784153770L),
smsMessage(id = 1981L, date = 1773790733566L),
smsMessage(id = 1985L, date = 1773872989602L),
smsMessage(id = 1986L, date = 1773899354039L),
smsMessage(id = 1987L, date = 1773950752506L),
)
assertEquals(listOf(1399L), SmsManager.pageByPhoneCandidates(candidates, params).map { it.id })
assertTrue(SmsManager.pageByPhoneCandidates(candidates, params.copy(offset = 10)).isEmpty())
}
@Test
fun upsertTopDateCandidatesNoOpWhenMaxIsZero() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 2000L), 0)
assertTrue(candidates.isEmpty())
}
@Test
fun buildMixedRowIdentityUsesTransportTypeAndRowId() {
assertEquals("sms:7", SmsManager.buildMixedRowIdentity(7L, "sms"))
assertEquals("mms:7", SmsManager.buildMixedRowIdentity(7L, "mms"))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, null))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, ""))
}
@Test
fun normalizeProviderDateMillisConvertsSecondsToMillis() {
assertEquals(1773944910000L, SmsManager.normalizeProviderDateMillis(1773944910L))
}
@Test
fun normalizeProviderDateMillisKeepsMillisUnchanged() {
assertEquals(1773944910123L, SmsManager.normalizeProviderDateMillis(1773944910123L))
}
@Test
fun normalizeProviderDateMillisKeepsHistoricMillisUnchanged() {
assertEquals(946684800000L, SmsManager.normalizeProviderDateMillis(946684800000L))
}
@Test
fun resolveMixedByPhoneRowStatusPreservesRealSmsStatus() {
assertEquals(64, SmsManager.resolveMixedByPhoneRowStatus("sms", 64))
assertEquals(32, SmsManager.resolveMixedByPhoneRowStatus(null, 32))
}
@Test
fun resolveMixedByPhoneRowStatusKeepsMmsOnSentinelValue() {
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("mms", 64))
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("MMS", null))
}
@Test
fun resolveMixedByPhoneRowStatusFallsBackToZeroWhenSmsStatusMissing() {
assertEquals(0, SmsManager.resolveMixedByPhoneRowStatus("sms", null))
}
@Test
fun resolveMixedByPhoneRowAddressPreservesProviderAddressWhenPresent() {
assertEquals(
"+12107588120",
SmsManager.resolveMixedByPhoneRowAddress("+12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressFallsBackToLookupNumberWhenProviderAddressMissing() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress(null, "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressCanPreserveLookupNumberWhenProviderAlreadyReturnsIt() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress("12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPreservesNonMatchingProviderAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("+13105550123", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPrefersResolvedMmsParticipantAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("insert-address-token", "12107588120", "+13105550123"),
)
}
@Test
fun selectPreferredMmsAddressPrefersType137AddressThatDoesNotMatchLookup() {
assertEquals(
"+13105550123",
SmsManager.selectPreferredMmsAddress(
listOf(
"+12107588120" to 151,
"+13105550123" to 137,
"+12107588120" to 130,
),
"12107588120",
),
)
}
@Test
fun selectPreferredMmsAddressFallsBackToFirstNormalizedAddressWhenOnlyLookupMatchesExist() {
assertEquals(
"+12107588120",
SmsManager.selectPreferredMmsAddress(
listOf(
"insert-address-token" to 137,
"+12107588120" to 151,
),
"12107588120",
),
)
}
@Test
fun isExplicitPhoneInputInvalidTrueWhenCallerSuppliesOnlyFormatting() {
val normalized = SmsManager.normalizePhoneNumberOrNull(" + ")
assertTrue(SmsManager.isExplicitPhoneInputInvalid(" + ", normalized))
}
@Test
fun hasSqlLikeWildcardDetectsPercentAndUnderscore() {
assertTrue(SmsManager.hasSqlLikeWildcard("+1555%1234"))
assertTrue(SmsManager.hasSqlLikeWildcard("+1555_1234"))
assertFalse(SmsManager.hasSqlLikeWildcard("+15551234"))
}
@Test
fun isExplicitPhoneInputInvalidRejectsLikeWildcardPhoneFilter() {
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555%1234", "+1555%1234"))
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555_1234", "+1555_1234"))
}
@Test
fun isExplicitPhoneInputInvalidFalseWhenPhoneWasOmitted() {
assertFalse(SmsManager.isExplicitPhoneInputInvalid(null, null))
assertFalse(SmsManager.isExplicitPhoneInputInvalid(" ", null))
}
@Test
fun mapMmsMsgBoxToSearchTypeCoversSearchRelevantMmsBoxes() {
assertEquals(1, SmsManager.mapMmsMsgBoxToSearchType(1))
assertEquals(2, SmsManager.mapMmsMsgBoxToSearchType(2))
assertEquals(3, SmsManager.mapMmsMsgBoxToSearchType(3))
assertEquals(4, SmsManager.mapMmsMsgBoxToSearchType(4))
assertEquals(5, SmsManager.mapMmsMsgBoxToSearchType(5))
assertEquals(6, SmsManager.mapMmsMsgBoxToSearchType(6))
}
@Test
fun mapMmsMsgBoxToSearchTypeLeavesUnsupportedBoxesUnmapped() {
assertNull(SmsManager.mapMmsMsgBoxToSearchType(0))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(99))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(null))
}
@Test
fun shouldUseConversationReviewByPhoneModeOnlyForMixedByPhoneReviewPulls() {
val active =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val disabledByMode = active.copy(conversationReview = false)
val disabledByMms = active.copy(includeMms = false)
val disabledByPhone = active.copy(phoneNumber = null)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(active))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMode))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMms))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByPhone))
}
@Test
fun effectiveSearchParamsRaisesConversationReviewLimitFloor() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
assertEquals(25, SmsManager.effectiveSearchParams(params).limit)
assertEquals(40, SmsManager.effectiveSearchParams(params.copy(limit = 40)).limit)
assertEquals(5, SmsManager.effectiveSearchParams(params.copy(conversationReview = false)).limit)
val singleResolvedContact = params.copy(phoneNumber = null, contactName = "Leah")
assertEquals(25, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit)
assertEquals(5, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567", "15557654321")).limit)
assertEquals(
SmsManager.effectiveSearchParams(params).limit,
SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit,
)
}
@Test
fun resolveSearchParamsCarriesSingleResolvedContactIntoReviewMode() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = "Leah",
phoneNumber = null,
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val beforeResolution = SmsManager.resolveSearchParams(params, normalizedPhoneNumber = null)
val singleResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
val multiResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567", "15557654321"),
)
val explicit =
SmsManager.resolveSearchParams(
params.copy(contactName = null, phoneNumber = "+12107588120"),
normalizedPhoneNumber = "12107588120",
)
val nonReview =
SmsManager.resolveSearchParams(
params.copy(conversationReview = false),
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
assertEquals(5, beforeResolution.limit)
assertEquals(25, singleResolved.limit)
assertEquals("15551234567", singleResolved.phoneNumber)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(singleResolved))
assertEquals(5, multiResolved.limit)
assertNull(multiResolved.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(multiResolved))
assertEquals(25, explicit.limit)
assertEquals("12107588120", explicit.phoneNumber)
assertEquals(5, nonReview.limit)
assertEquals("15551234567", nonReview.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(nonReview))
}
@Test
fun canonicalizeMixedPathPhoneFiltersDedupesEquivalentExplicitAndContactNumbers() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567")),
)
}
@Test
fun canonicalizeMixedPathPhoneFiltersDropsBlankByPhoneValues() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "+", " ")),
)
}
@Test
fun buildQueryMetadataUsesCanonicalizedSingleMixedFilterAsEligible() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val canonical = SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567"))
val metadata = SmsManager.buildQueryMetadata(params, canonical, emptyList())
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
}
@Test
fun requestedMixedByPhoneCandidateWindowAddsOffsetAndLimitSafely() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertEquals(500L, SmsManager.requestedMixedByPhoneCandidateWindow(params))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseAtSupportedBoundary() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowTrueWhenSingleNumberMixedWindowTooLarge() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 301)
assertTrue(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseForSmsOnlyQueries() {
val params = SmsManager.QueryParams(includeMms = false, phoneNumber = "+15551234567", limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseWhenMultiplePhoneNumbersDisableMixedByPhonePath() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = null, limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567", "+15557654321")))
}
@Test
fun mixedByPhoneWindowErrorMentionsSupportedWindow() {
assertEquals(
"INVALID_REQUEST: includeMms offset+limit exceeds supported window (500)",
SmsManager.mixedByPhoneWindowError(),
)
}
@Test
fun buildQueryMetadataMarksIneligibleWhenIncludeMmsNotRequested() {
val params = SmsManager.QueryParams(includeMms = false)
val metadata = SmsManager.buildQueryMetadata(params, emptyList(), emptyList())
assertFalse(metadata.mmsRequested)
assertFalse(metadata.mmsEligible)
assertFalse(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksEligibleAttemptedButNotIncludedForSingleNumberFallback() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val messages = listOf(smsMessage(id = 1L, date = 1000L))
val metadata = SmsManager.buildQueryMetadata(params, listOf("+15551234567"), messages)
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun isMmsTransportRowTrueOnlyForMmsTransport() {
assertTrue(SmsManager.isMmsTransportRow(smsMessage(id = 1L, date = 1000L, transportType = "mms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 2L, date = 1000L, transportType = "sms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 3L, date = 1000L, transportType = null)))
}
@Test
fun shouldHydrateMmsByPhoneRowTrueOnlyForMmsTransportWithBlankBodyOrZeroType() {
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", null, 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "", 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("sms", null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow(null, null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 1))
}
@Test
fun buildQueryMetadataDoesNotTreatSmsStatusSentinelAsMmsInclusion() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val smsLikeMessage = smsMessage(id = 7L, date = 1000L, status = -1, transportType = "sms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(smsLikeMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksIncludedWhenMixedQueryYieldsMmsTransportRow() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val mmsTransportMessage = smsMessage(id = 7L, date = 1000L, status = 0, body = null, transportType = "mms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(mmsTransportMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertTrue(metadata.mmsIncluded)
}
}

View File

@@ -26,6 +26,16 @@ class SystemHandlerTest {
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleSystemNotify_rejectsInvalidRequestObject() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
val result = handler.handleSystemNotify("""{"title":"OpenClaw"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleSystemNotify_postsNotification() {
val poster = FakePoster(authorized = true)
@@ -37,6 +47,23 @@ class SystemHandlerTest {
assertEquals(1, poster.posts)
}
@Test
fun handleSystemNotify_trimsAndPassesOptionalFields() {
val poster = FakePoster(authorized = true)
val handler = SystemHandler.forTesting(poster = poster)
val result =
handler.handleSystemNotify(
"""{"title":" OpenClaw ","body":" done ","priority":" passive ","sound":" silent "}""",
)
assertTrue(result.ok)
assertEquals("OpenClaw", poster.lastRequest?.title)
assertEquals("done", poster.lastRequest?.body)
assertEquals("passive", poster.lastRequest?.priority)
assertEquals("silent", poster.lastRequest?.sound)
}
@Test
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))
@@ -55,6 +82,7 @@ class SystemHandlerTest {
assertFalse(result.ok)
assertEquals("UNAVAILABLE", result.error?.code)
assertEquals("NOTIFICATION_FAILED: boom", result.error?.message)
}
}
@@ -63,11 +91,14 @@ private class FakePoster(
) : SystemNotificationPoster {
var posts: Int = 0
private set
var lastRequest: SystemNotifyRequest? = null
private set
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest) {
posts += 1
lastRequest = request
}
}

View File

@@ -86,13 +86,15 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.send", OpenClawSmsCommand.Send.rawValue)
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@@ -155,9 +155,13 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHost = "",
manualPort = "",
manualTls = true,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
@@ -179,9 +183,13 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHost = "",
manualPort = "",
manualTls = true,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
@@ -194,6 +202,74 @@ class GatewayConfigResolverTest {
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
@Test
fun resolveGatewayConnectConfigManualPreservesBootstrapTokenWhenNoReplacementAuthExists() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "192.168.31.100",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "192.168.31.100",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenReplacementPasswordExists() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "192.168.31.100",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "192.168.31.100",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "password-1",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("password-1", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenEndpointChanges() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "192.168.31.100",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "192.168.31.101",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("192.168.31.101", resolved?.host)
}
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}

View File

@@ -0,0 +1,27 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OnboardingFlowLogicTest {
@Test
fun blocksFinishWhenOnlyOperatorIsConnected() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false))
}
@Test
fun blocksFinishWhenDisconnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false))
}
@Test
fun blocksFinishWhenOnlyNodeIsConnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true))
}
@Test
fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
}
}

View File

@@ -0,0 +1,35 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SettingsSheetNotificationAppsTest {
@Test
fun resolveNotificationCandidatePackages_keepsConfiguredPackagesVisible() {
val packages =
resolveNotificationCandidatePackages(
launcherPackages = setOf("com.example.launcher"),
recentPackages = listOf("com.example.recent", "com.example.launcher"),
configuredPackages = setOf("com.example.configured"),
appPackageName = "ai.openclaw.app",
)
assertEquals(
setOf("com.example.launcher", "com.example.recent", "com.example.configured"),
packages,
)
}
@Test
fun resolveNotificationCandidatePackages_filtersBlankAndSelfPackages() {
val packages =
resolveNotificationCandidatePackages(
launcherPackages = setOf(" ", "ai.openclaw.app"),
recentPackages = listOf("com.example.recent", " "),
configuredPackages = setOf("ai.openclaw.app", "com.example.configured"),
appPackageName = "ai.openclaw.app",
)
assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
}
}

View File

@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.25
OPENCLAW_MARKETING_VERSION = 2026.3.25
OPENCLAW_BUILD_VERSION = 202603250
OPENCLAW_GATEWAY_VERSION = 2026.3.31
OPENCLAW_MARKETING_VERSION = 2026.3.31
OPENCLAW_BUILD_VERSION = 2026033190
#include? "../build/Version.xcconfig"

View File

@@ -65,9 +65,9 @@ Release behavior:
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.22-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.22`
- `CFBundleVersion = next TestFlight build number for 2026.3.22`
- A root version like `2026.3.31-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.31`
- `CFBundleVersion = next TestFlight build number for 2026.3.31`
Required env for beta builds:

View File

@@ -69,6 +69,13 @@ enum GatewaySettingsStore {
account: self.preferredGatewayStableIDAccount)
}
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
}
static func loadLastDiscoveredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
service: self.gatewayService,
@@ -89,6 +96,13 @@ enum GatewaySettingsStore {
account: self.lastDiscoveredGatewayStableIDAccount)
}
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
@@ -119,6 +133,12 @@ enum GatewaySettingsStore {
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func clearGatewayBootstrapToken(instanceId: String) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,

View File

@@ -1,4 +1,4 @@
import ActivityKit
@preconcurrency import ActivityKit
import Foundation
import os

View File

@@ -1697,14 +1697,24 @@ extension NodeAppModel {
password: password,
nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
self.startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
if self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
stableID: effectiveStableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
} else {
self.operatorGatewayTask = nil
Task { await self.operatorGateway.disconnect() }
}
self.startNodeGatewayLoop(
url: url,
stableID: effectiveStableID,
@@ -1785,6 +1795,86 @@ private extension NodeAppModel {
self.apnsLastRegisteredTokenHex = nil
}
func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
stableID _: String) -> Bool
{
Self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
}
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
}
static func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedBootstrapToken.isEmpty {
return false
}
return hasStoredOperatorToken
}
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
guard let config else { return nil }
let trimmedBootstrapToken = config.bootstrapToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmedBootstrapToken.isEmpty else { return config }
return GatewayConnectConfig(
url: config.url,
stableID: config.stableID,
tls: config.tls,
token: config.token,
bootstrapToken: nil,
password: config.password,
nodeOptions: config.nodeOptions)
}
func currentGatewayReconnectAuth(
fallbackToken: String?,
fallbackBootstrapToken: String?,
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
{
if let cfg = self.activeGatewayConnectConfig {
return (cfg.token, cfg.bootstrapToken, cfg.password)
}
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
}
func clearPersistedGatewayBootstrapTokenIfNeeded() {
// Always drop the in-memory bootstrap token after the first successful
// bootstrap connect so reconnect loops cannot reuse a spent token.
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmedInstanceId.isEmpty else { return }
guard
GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil
else { return }
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
}
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return }
@@ -1841,11 +1931,15 @@ private extension NodeAppModel {
displayName: nodeOptions.clientDisplayName)
do {
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
try await self.operatorGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: operatorOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
@@ -1948,12 +2042,16 @@ private extension NodeAppModel {
do {
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
try await self.nodeGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: currentOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
@@ -1965,6 +2063,30 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
let usedBootstrapToken =
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty == false
if usedBootstrapToken {
await MainActor.run {
self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
stableID: stableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: stableID,
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
nodeOptions: currentOptions,
sessionBox: sessionBox)
}
}
}
let relayData = await MainActor.run {
(
sessionKey: self.mainSessionKey,
@@ -1975,8 +2097,8 @@ private extension NodeAppModel {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
gatewayURLString: url.absoluteString,
token: token,
password: password,
token: reconnectAuth.token,
password: reconnectAuth.password,
sessionKey: relayData.sessionKey,
deliveryChannel: relayData.deliveryChannel,
deliveryTo: relayData.deliveryTo))
@@ -3015,6 +3137,20 @@ extension NodeAppModel {
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}
static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: hasStoredOperatorToken)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -1008,6 +1008,11 @@ struct SettingsTab: View {
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
GatewaySettingsStore.clearLastGatewayConnection()
GatewaySettingsStore.clearPreferredGatewayStableID()
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
// Resetting onboarding should also forget trusted gateway TLS fingerprints.
// Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop.
GatewayTLSStore.clearAllFingerprints()
OnboardingStateStore.reset()
// RootCanvas also short-circuits onboarding when these are true.

View File

@@ -5,6 +5,7 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
@MainActor
private func makeController() -> GatewayConnectionController {
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
}
@@ -32,8 +33,7 @@ import Testing
}
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
GatewayTLSStore.clearFingerprint(stableID: stableID)
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
@@ -126,4 +126,21 @@ import Testing
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
}
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
let stableID1 = "test|\(UUID().uuidString)"
let stableID2 = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearAllFingerprints() }
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
GatewayTLSStore.clearAllFingerprints()
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
}
}

View File

@@ -96,6 +96,64 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
hasStoredOperatorToken: true)
)
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: nil,
password: nil,
hasStoredOperatorToken: false)
)
#expect(
NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: nil,
password: nil,
hasStoredOperatorToken: true)
)
#expect(
NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: "shared-token",
bootstrapToken: "fresh-bootstrap-token",
password: nil,
hasStoredOperatorToken: false)
)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
let config = GatewayConnectConfig(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
tls: nil,
token: nil,
bootstrapToken: "spent-bootstrap-token",
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil))
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
#expect(cleared?.bootstrapToken == nil)
#expect(cleared?.url == config.url)
#expect(cleared?.stableID == config.stableID)
#expect(cleared?.token == config.token)
#expect(cleared?.password == config.password)
#expect(cleared?.nodeOptions.role == config.nodeOptions.role)
}
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)

View File

@@ -43,7 +43,7 @@
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
"revision" : "8659b70d386d02f831e277386b3216023ccc707e"
}
},
{
@@ -96,8 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c",
"version" : "0.3.0"
"revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b",
"version" : "0.4.0"
}
},
{

View File

@@ -16,9 +16,9 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../../Swabble"),

View File

@@ -768,10 +768,8 @@ struct DebugSettings: View {
}
private func loadSessionStorePath() {
let url = self.configURL()
let parsed = OpenClawConfigFile.loadDict()
guard
let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String
else {
@@ -783,28 +781,14 @@ struct DebugSettings: View {
private func saveSessionStorePath() {
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
var root: [String: Any] = [:]
let url = self.configURL()
if let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
{
root = parsed
}
var root = OpenClawConfigFile.loadDict()
var session = root["session"] as? [String: Any] ?? [:]
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
root["session"] = session
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
self.sessionStoreSaveError = nil
} catch {
self.sessionStoreSaveError = error.localizedDescription
}
OpenClawConfigFile.saveDict(root)
self.sessionStoreSaveError = nil
}
private var bindingOverride: Binding<String> {
@@ -828,10 +812,6 @@ struct DebugSettings: View {
private var canRestartGateway: Bool {
self.state.connectionMode == .local
}
private func configURL() -> URL {
OpenClawPaths.configURL
}
}
extension DebugSettings {

View File

@@ -558,12 +558,16 @@ extension GatewayConnection {
func skillsInstall(
name: String,
installId: String,
dangerouslyForceUnsafeInstall: Bool? = nil,
timeoutMs: Int? = nil) async throws -> SkillInstallResult
{
var params: [String: AnyCodable] = [
"name": AnyCodable(name),
"installId": AnyCodable(installId),
]
if let dangerouslyForceUnsafeInstall {
params["dangerouslyForceUnsafeInstall"] = AnyCodable(dangerouslyForceUnsafeInstall)
}
if let timeoutMs {
params["timeoutMs"] = AnyCodable(timeoutMs)
}

View File

@@ -193,7 +193,7 @@ enum GatewayEnvironment {
let port = self.gatewayPort()
if let gatewayBin {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -201,7 +201,7 @@ enum GatewayEnvironment {
case let .success(resolvedRuntime) = runtime
{
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -291,6 +291,17 @@ enum GatewayEnvironment {
// MARK: - Internals
/// Exposed for tests so CLI version output normalization stays local to gateway checks.
static func normalizeGatewayVersionOutput(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !normalized.isEmpty else {
return nil
}
if normalized.lowercased().hasPrefix("openclaw ") {
normalized = String(normalized.dropFirst("openclaw ".count))
}
return normalized
}
private static func readGatewayVersion(binary: String) -> Semver? {
let start = Date()
let process = Process()
@@ -317,9 +328,8 @@ enum GatewayEnvironment {
bin=\(binary, privacy: .public)
""")
}
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
let raw = String(data: data, encoding: .utf8)
return Semver.parse(self.normalizeGatewayVersionOutput(raw))
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
self.logger.error(

View File

@@ -44,6 +44,7 @@ final class GatewayProcessManager {
private var logRefreshTask: Task<Void, Never>?
#if DEBUG
private var testingConnection: GatewayConnection?
private var testingSkipControlChannelRefresh = false
#endif
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process")
@@ -364,6 +365,11 @@ final class GatewayProcessManager {
}
private func refreshControlChannelIfNeeded(reason: String) {
#if DEBUG
if self.testingSkipControlChannelRefresh {
return
}
#endif
switch ControlChannel.shared.state {
case .connected, .connecting:
return
@@ -421,6 +427,10 @@ extension GatewayProcessManager {
self.testingConnection = connection
}
func setTestingSkipControlChannelRefresh(_ skip: Bool) {
self.testingSkipControlChannelRefresh = skip
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
@@ -428,5 +438,9 @@ extension GatewayProcessManager {
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
func _testAttachExistingGatewayIfAvailable() async -> Bool {
await self.attachExistingGatewayIfAvailable()
}
}
#endif

View File

@@ -16,8 +16,20 @@ enum HostEnvSecurityPolicy {
"RUBYOPT",
"BASH_ENV",
"ENV",
"BROWSER",
"GIT_EDITOR",
"GIT_EXTERNAL_DIFF",
"GIT_EXEC_PATH",
"GIT_SEQUENCE_EDITOR",
"GIT_TEMPLATE_DIR",
"GIT_SSL_NO_VERIFY",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
"SHELLOPTS",
"PS4",
@@ -45,6 +57,9 @@ enum HostEnvSecurityPolicy {
"GIT_SSH",
"GIT_PROXY_COMMAND",
"GIT_ASKPASS",
"GIT_SSL_NO_VERIFY",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"SSH_ASKPASS",
"LESSOPEN",
"LESSCLOSE",
@@ -73,13 +88,60 @@ enum HostEnvSecurityPolicy {
"PHP_INI_SCAN_DIR",
"DENO_DIR",
"BUN_CONFIG_REGISTRY",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"NODE_TLS_REJECT_UNAUTHORIZED",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"DOCKER_HOST",
"DOCKER_TLS_VERIFY",
"DOCKER_CERT_PATH",
"PIP_INDEX_URL",
"PIP_PYPI_URL",
"PIP_EXTRA_INDEX_URL",
"PIP_CONFIG_FILE",
"PIP_FIND_LINKS",
"PIP_TRUSTED_HOST",
"UV_INDEX",
"UV_INDEX_URL",
"UV_EXTRA_INDEX_URL",
"UV_DEFAULT_INDEX",
"DOCKER_HOST",
"DOCKER_TLS_VERIFY",
"DOCKER_CERT_PATH",
"DOCKER_CONTEXT",
"LIBRARY_PATH",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"GOPROXY",
"GONOSUMCHECK",
"GONOSUMDB",
"GONOPROXY",
"GOPRIVATE",
"GOENV",
"GOPATH",
"PYTHONUSERBASE",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
"GEM_HOME",
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"XDG_CONFIG_HOME"
"XDG_CONFIG_HOME",
"AWS_CONFIG_FILE"
]
static let blockedOverridePrefixes: [String] = [

View File

@@ -44,6 +44,7 @@ enum OpenClawConfigFile {
let previousData = try? Data(contentsOf: url)
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
let previousBytes = previousData?.count
let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path)
let hadMetaBefore = self.hasMeta(previousRoot)
let gatewayModeBefore = self.gatewayMode(previousRoot)
@@ -57,6 +58,7 @@ enum OpenClawConfigFile {
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
let nextBytes = data.count
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
let gatewayModeAfter = self.gatewayMode(output)
let suspicious = self.configWriteSuspiciousReasons(
existsBefore: previousData != nil,
@@ -74,6 +76,18 @@ enum OpenClawConfigFile {
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": nextBytes,
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
"nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(),
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
"nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(),
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
"nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(),
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
"nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(),
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
"nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(),
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
@@ -384,6 +398,23 @@ enum OpenClawConfigFile {
return date.timeIntervalSince1970 * 1000
}
private static func fileAttributeInt(_ value: Any?) -> Int? {
if let number = value as? NSNumber { return number.intValue }
if let number = value as? Int { return number }
return nil
}
private static func fileSystemNumber(_ value: Any?) -> String? {
if let number = value as? NSNumber { return number.stringValue }
if let number = value as? Int { return String(number) }
return nil
}
private static func posixMode(_ value: Any?) -> Int? {
guard let mode = self.fileAttributeInt(value) else { return nil }
return mode & 0o777
}
private static func configFingerprint(
data: Data,
root: [String: Any]?,
@@ -396,6 +427,12 @@ enum OpenClawConfigFile {
"bytes": data.count,
"mtimeMs": self.fileTimestampMs(attributes?[.modificationDate]) ?? NSNull(),
"ctimeMs": self.fileTimestampMs(attributes?[.creationDate]) ?? NSNull(),
"dev": self.fileSystemNumber(attributes?[.systemNumber]) ?? NSNull(),
"ino": self.fileSystemNumber(attributes?[.systemFileNumber]) ?? NSNull(),
"mode": self.posixMode(attributes?[.posixPermissions]) ?? NSNull(),
"nlink": self.fileAttributeInt(attributes?[.referenceCount]) ?? NSNull(),
"uid": self.fileAttributeInt(attributes?[.ownerAccountID]) ?? NSNull(),
"gid": self.fileAttributeInt(attributes?[.groupOwnerAccountID]) ?? NSNull(),
"hasMeta": self.hasMeta(root),
"gatewayMode": self.gatewayMode(root) ?? NSNull(),
"observedAt": observedAt,
@@ -408,6 +445,12 @@ enum OpenClawConfigFile {
(left["bytes"] as? Int) == (right["bytes"] as? Int) &&
(left["mtimeMs"] as? Double) == (right["mtimeMs"] as? Double) &&
(left["ctimeMs"] as? Double) == (right["ctimeMs"] as? Double) &&
(left["dev"] as? String) == (right["dev"] as? String) &&
(left["ino"] as? String) == (right["ino"] as? String) &&
(left["mode"] as? Int) == (right["mode"] as? Int) &&
(left["nlink"] as? Int) == (right["nlink"] as? Int) &&
(left["uid"] as? Int) == (right["uid"] as? Int) &&
(left["gid"] as? Int) == (right["gid"] as? Int) &&
(left["hasMeta"] as? Bool) == (right["hasMeta"] as? Bool) &&
(left["gatewayMode"] as? String) == (right["gatewayMode"] as? String)
}
@@ -509,6 +552,12 @@ enum OpenClawConfigFile {
"bytes": current["bytes"] ?? NSNull(),
"mtimeMs": current["mtimeMs"] ?? NSNull(),
"ctimeMs": current["ctimeMs"] ?? NSNull(),
"dev": current["dev"] ?? NSNull(),
"ino": current["ino"] ?? NSNull(),
"mode": current["mode"] ?? NSNull(),
"nlink": current["nlink"] ?? NSNull(),
"uid": current["uid"] ?? NSNull(),
"gid": current["gid"] ?? NSNull(),
"hasMeta": current["hasMeta"] ?? false,
"gatewayMode": current["gatewayMode"] ?? NSNull(),
"suspicious": suspicious,
@@ -516,11 +565,23 @@ enum OpenClawConfigFile {
"lastKnownGoodBytes": lastKnownGood?["bytes"] ?? NSNull(),
"lastKnownGoodMtimeMs": lastKnownGood?["mtimeMs"] ?? NSNull(),
"lastKnownGoodCtimeMs": lastKnownGood?["ctimeMs"] ?? NSNull(),
"lastKnownGoodDev": lastKnownGood?["dev"] ?? NSNull(),
"lastKnownGoodIno": lastKnownGood?["ino"] ?? NSNull(),
"lastKnownGoodMode": lastKnownGood?["mode"] ?? NSNull(),
"lastKnownGoodNlink": lastKnownGood?["nlink"] ?? NSNull(),
"lastKnownGoodUid": lastKnownGood?["uid"] ?? NSNull(),
"lastKnownGoodGid": lastKnownGood?["gid"] ?? NSNull(),
"lastKnownGoodGatewayMode": lastKnownGood?["gatewayMode"] ?? NSNull(),
"backupHash": backup?["hash"] ?? NSNull(),
"backupBytes": backup?["bytes"] ?? NSNull(),
"backupMtimeMs": backup?["mtimeMs"] ?? NSNull(),
"backupCtimeMs": backup?["ctimeMs"] ?? NSNull(),
"backupDev": backup?["dev"] ?? NSNull(),
"backupIno": backup?["ino"] ?? NSNull(),
"backupMode": backup?["mode"] ?? NSNull(),
"backupNlink": backup?["nlink"] ?? NSNull(),
"backupUid": backup?["uid"] ?? NSNull(),
"backupGid": backup?["gid"] ?? NSNull(),
"backupGatewayMode": backup?["gatewayMode"] ?? NSNull(),
"clobberedPath": clobberedPath ?? NSNull(),
])

View File

@@ -23,6 +23,9 @@ actor PortGuardian {
private var records: [Record] = []
private let logger = Logger(subsystem: "ai.openclaw", category: "portguard")
#if DEBUG
private var testingDescriptors: [Int: Descriptor] = [:]
#endif
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("OpenClaw", isDirectory: true)
@@ -130,6 +133,11 @@ actor PortGuardian {
}
func describe(port: Int) async -> Descriptor? {
#if DEBUG
if let descriptor = self.testingDescriptors[port] {
return descriptor
}
#endif
guard let listener = await self.listeners(on: port).first else { return nil }
let path = Self.executablePath(for: listener.pid)
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
@@ -368,8 +376,12 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return true }
return false
case .local:
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
if full.contains("gateway-daemon") { return true }
// Preserve both the legacy hidden alias and the current service process title.
if full.contains("gateway-daemon") || full.contains("openclaw-gateway")
|| cmd.contains("openclaw-gateway")
{
return true
}
// If args are unavailable, treat a CLI listener as expected.
if cmd.contains("openclaw"), full == cmd { return true }
return false
@@ -402,6 +414,18 @@ actor PortGuardian {
}
}
#if DEBUG
extension PortGuardian {
func setTestingDescriptor(_ descriptor: Descriptor?, forPort port: Int) {
if let descriptor {
self.testingDescriptors[port] = descriptor
} else {
self.testingDescriptors.removeValue(forKey: port)
}
}
}
#endif
#if DEBUG
extension PortGuardian {
static func _testParseListeners(_ text: String) -> [(

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.25</string>
<string>2026.3.31</string>
<key>CFBundleVersion</key>
<string>202603250</string>
<string>2026033190</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -14,10 +14,11 @@ struct WideAreaGatewayBeacon: Equatable {
}
enum WideAreaGatewayDiscovery {
private static let maxCandidates = 40
private static let digPath = "/usr/bin/dig"
private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
// Security: wide-area discovery must trust only the Tailscale MagicDNS resolver.
// Probing arbitrary tailnet peers lets the fastest responder become DNS-SD authority.
private static let tailscaleDNSResolver = "100.100.100.100"
struct DiscoveryContext {
var tailscaleStatus: @Sendable () -> String?
@@ -39,27 +40,16 @@ enum WideAreaGatewayDiscovery {
timeoutSeconds - Date().timeIntervalSince(startedAt)
}
guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver(
candidates: &candidates,
guard let statusJson = context.tailscaleStatus(),
!collectTailnetIPv4s(statusJson: statusJson).isEmpty,
let discovery = loadWideAreaPtrRecords(
remaining: remaining,
dig: context.dig)
else {
return []
}
else { return [] }
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return [] }
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_openclaw-gw._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty
else {
return []
}
let domainTrimmed = discovery.domainTrimmed
let ptrLines = discovery.ptrLines
let nameserver = self.tailscaleDNSResolver
var beacons: [WideAreaGatewayBeacon] = []
for raw in ptrLines {
@@ -148,68 +138,26 @@ enum WideAreaGatewayDiscovery {
return output
}
private static func findNameserver(
candidates: inout [String],
private static func loadWideAreaPtrRecords(
remaining: () -> TimeInterval,
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?)
-> (domainTrimmed: String, ptrLines: [Substring])?
{
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil }
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_openclaw-gw._tcp.\(domainTrimmed)"
let budget = max(0, remaining())
if budget <= 0 { return nil }
let ips = candidates
candidates.removeAll(keepingCapacity: true)
if ips.isEmpty { return nil }
final class ProbeState: @unchecked Sendable {
let lock = NSLock()
var nextIndex = 0
var found: String?
guard let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(self.tailscaleDNSResolver)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
let ptrLines = stdout.split(whereSeparator: \.isNewline).nonEmpty
else {
return nil
}
let state = ProbeState()
let deadline = Date().addingTimeInterval(max(0, remaining()))
let workerCount = min(self.nameserverProbeConcurrency, ips.count)
let group = DispatchGroup()
for _ in 0..<workerCount {
group.enter()
DispatchQueue.global(qos: .utility).async {
defer { group.leave() }
while Date() < deadline {
state.lock.lock()
if state.found != nil {
state.lock.unlock()
return
}
let i = state.nextIndex
state.nextIndex += 1
state.lock.unlock()
if i >= ips.count { return }
let ip = ips[i]
let budget = deadline.timeIntervalSinceNow
if budget <= 0 { return }
if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false
{
state.lock.lock()
if state.found == nil {
state.found = ip
}
state.lock.unlock()
return
}
}
}
}
_ = group.wait(timeout: .now() + max(0.0, remaining()))
return state.found
return (domainTrimmed, ptrLines)
}
private static func runDig(args: [String], timeout: TimeInterval) -> String? {

View File

@@ -9,6 +9,7 @@ public enum ErrorCode: String, Codable, Sendable {
case notPaired = "NOT_PAIRED"
case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST"
case approvalNotFound = "APPROVAL_NOT_FOUND"
case unavailable = "UNAVAILABLE"
}
@@ -2234,21 +2235,29 @@ public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public let identity: [String: AnyCodable]?
public let workspace: String?
public let model: [String: AnyCodable]?
public init(
id: String,
name: String?,
identity: [String: AnyCodable]?)
identity: [String: AnyCodable]?,
workspace: String?,
model: [String: AnyCodable]?)
{
self.id = id
self.name = name
self.identity = identity
self.workspace = workspace
self.model = model
}
private enum CodingKeys: String, CodingKey {
case id
case name
case identity
case workspace
case model
}
}
@@ -3434,6 +3443,90 @@ public struct ExecApprovalResolveParams: Codable, Sendable {
}
}
public struct PluginApprovalRequestParams: Codable, Sendable {
public let pluginid: String?
public let title: String
public let description: String
public let severity: String?
public let toolname: String?
public let toolcallid: String?
public let agentid: String?
public let sessionkey: String?
public let turnsourcechannel: String?
public let turnsourceto: String?
public let turnsourceaccountid: String?
public let turnsourcethreadid: AnyCodable?
public let timeoutms: Int?
public let twophase: Bool?
public init(
pluginid: String?,
title: String,
description: String,
severity: String?,
toolname: String?,
toolcallid: String?,
agentid: String?,
sessionkey: String?,
turnsourcechannel: String?,
turnsourceto: String?,
turnsourceaccountid: String?,
turnsourcethreadid: AnyCodable?,
timeoutms: Int?,
twophase: Bool?)
{
self.pluginid = pluginid
self.title = title
self.description = description
self.severity = severity
self.toolname = toolname
self.toolcallid = toolcallid
self.agentid = agentid
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.timeoutms = timeoutms
self.twophase = twophase
}
private enum CodingKeys: String, CodingKey {
case pluginid = "pluginId"
case title
case description
case severity
case toolname = "toolName"
case toolcallid = "toolCallId"
case agentid = "agentId"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
}
public struct PluginApprovalResolveParams: Codable, Sendable {
public let id: String
public let decision: String
public init(
id: String,
decision: String)
{
self.id = id
self.decision = decision
}
private enum CodingKeys: String, CodingKey {
case id
case decision
}
}
public struct DevicePairListParams: Codable, Sendable {}
public struct DevicePairApproveParams: Codable, Sendable {
@@ -3637,6 +3730,10 @@ public struct ChatSendParams: Codable, Sendable {
public let message: String
public let thinking: String?
public let deliver: Bool?
public let originatingchannel: String?
public let originatingto: String?
public let originatingaccountid: String?
public let originatingthreadid: String?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
@@ -3648,6 +3745,10 @@ public struct ChatSendParams: Codable, Sendable {
message: String,
thinking: String?,
deliver: Bool?,
originatingchannel: String?,
originatingto: String?,
originatingaccountid: String?,
originatingthreadid: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
@@ -3658,6 +3759,10 @@ public struct ChatSendParams: Codable, Sendable {
self.message = message
self.thinking = thinking
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
@@ -3670,6 +3775,10 @@ public struct ChatSendParams: Codable, Sendable {
case message
case thinking
case deliver
case originatingchannel = "originatingChannel"
case originatingto = "originatingTo"
case originatingaccountid = "originatingAccountId"
case originatingthreadid = "originatingThreadId"
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"

View File

@@ -19,6 +19,15 @@ struct GatewayEnvironmentTests {
#expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
// Product-prefixed output from `openclaw --version` should NOT parse as semver
// (the prefix must be stripped by the caller, not the parser).
#expect(Semver.parse("OpenClaw 2026.3.23-1") == nil)
}
@Test func `gateway version output strips product prefix before parsing`() {
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(" OpenClaw 2026.3.23-1 \n")
#expect(normalized == "2026.3.23-1")
#expect(Semver.parse(normalized) == Semver(major: 2026, minor: 3, patch: 23))
}
@Test func `semver compatibility requires same major and not older`() {

View File

@@ -7,7 +7,7 @@ struct GatewayLaunchAgentManagerTests {
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"ProgramArguments": ["openclaw", "gateway", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"OPENCLAW_GATEWAY_TOKEN": " secret ",
"OPENCLAW_GATEWAY_PASSWORD": "pw",
@@ -28,7 +28,7 @@ struct GatewayLaunchAgentManagerTests {
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789"],
"ProgramArguments": ["openclaw", "gateway", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])

View File

@@ -35,4 +35,92 @@ struct GatewayProcessManagerTests {
#expect(ready)
#expect(manager.lastFailureReason == nil)
}
@Test func `attaches to existing gateway without spawning launchd`() async throws {
let healthData = Data(
"""
{
"ok": true,
"ts": 1,
"durationMs": 0,
"channels": {
"telegram": {
"configured": true,
"linked": true,
"authAgeMs": 60000
}
},
"channelOrder": ["telegram"],
"channelLabels": {
"telegram": "Telegram"
},
"heartbeatSeconds": 30,
"sessions": {
"path": "/tmp/sessions",
"count": 1,
"recent": []
}
}
""".utf8)
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": \(String(decoding: healthData, as: UTF8.self))
}
"""
task.emitReceiveSuccess(.data(Data(json.utf8)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let port = GatewayEnvironment.gatewayPort()
let descriptor = PortGuardian.Descriptor(
pid: 4242,
command: "openclaw-gateway",
executablePath: "/tmp/openclaw-gateway")
let manager = GatewayProcessManager.shared
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
manager.setTestingConnection(connection)
manager.setTestingSkipControlChannelRefresh(true)
manager.setTestingLastFailureReason("stale")
func cleanup() async {
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
manager.setTestingConnection(nil)
manager.setTestingSkipControlChannelRefresh(false)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
}
do {
let attached = await manager._testAttachExistingGatewayIfAvailable()
#expect(attached)
#expect(manager.lastFailureReason == nil)
guard case let .attachedExisting(statusDetails) = manager.status else {
Issue.record("expected attachedExisting status")
await cleanup()
return
}
let details = try #require(statusDetails)
#expect(details.contains("port \(port)"))
#expect(details.contains("Telegram linked"))
#expect(details.contains("auth 1m"))
#expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway"))
await cleanup()
} catch {
await cleanup()
throw error
}
}
}

View File

@@ -167,6 +167,11 @@ struct LowCoverageHelperTests {
fullCommand: "python server.py",
port: 18789, mode: .local) == false)
#expect(PortGuardian._testIsExpected(
command: "node",
fullCommand: "openclaw-gateway",
port: 18789, mode: .local) == true)
#expect(PortGuardian._testIsExpected(
command: "node",
fullCommand: "node /path/to/gateway-daemon",

View File

@@ -3,17 +3,19 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct NodeServiceManagerTests {
@Test func `builds node service commands with current CLI shape`() throws {
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
@Test func `builds node service commands with current CLI shape`() async throws {
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try makeExecutableForTests(at: openclawPath)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try makeExecutableForTests(at: openclawPath)
let start = NodeServiceManager._testServiceCommand(["start"])
#expect(start == [openclawPath.path, "node", "start", "--json"])
let start = NodeServiceManager._testServiceCommand(["start"])
#expect(start == [openclawPath.path, "node", "start", "--json"])
let stop = NodeServiceManager._testServiceCommand(["stop"])
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
let stop = NodeServiceManager._testServiceCommand(["stop"])
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
}
}
}

View File

@@ -133,6 +133,10 @@ struct OpenClawConfigFileTests {
#expect(auditRoot?["event"] as? String == "config.write")
#expect(auditRoot?["result"] as? String == "success")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["previousMode"] is NSNull)
#expect(auditRoot?["nextMode"] is NSNumber)
#expect(auditRoot?["previousIno"] is NSNull)
#expect(auditRoot?["nextIno"] as? String != nil)
}
}
@@ -188,6 +192,10 @@ struct OpenClawConfigFileTests {
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["mode"] is NSNumber)
#expect(auditRoot?["ino"] as? String != nil)
#expect(auditRoot?["lastKnownGoodMode"] is NSNumber)
#expect(auditRoot?["backupMode"] is NSNull)
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
#expect(suspicious.contains("update-channel-only-root"))

View File

@@ -1,10 +1,37 @@
import Darwin
import Foundation
import Testing
@testable import OpenClawDiscovery
private final class NameserverQueryLog: @unchecked Sendable {
private let lock = NSLock()
private var nameservers: [String] = []
func record(_ nameserver: String) {
self.lock.lock()
defer { self.lock.unlock() }
self.nameservers.append(nameserver)
}
func count(matching nameserver: String) -> Int {
self.lock.lock()
defer { self.lock.unlock() }
return self.nameservers.filter { $0 == nameserver }.count
}
}
@Suite(.serialized)
struct WideAreaGatewayDiscoveryTests {
@Test func `discovers beacon from tailnet dns sd fallback`() {
let originalWideAreaDomain = getenv("OPENCLAW_WIDE_AREA_DOMAIN").map { String(cString: $0) }
setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1)
defer {
if let originalWideAreaDomain {
setenv("OPENCLAW_WIDE_AREA_DOMAIN", originalWideAreaDomain, 1)
} else {
unsetenv("OPENCLAW_WIDE_AREA_DOMAIN")
}
}
let statusJson = """
{
"Self": { "TailscaleIPs": ["100.69.232.64"] },
@@ -20,7 +47,7 @@ struct WideAreaGatewayDiscoveryTests {
let recordType = args.last ?? ""
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
if nameserver == "@100.100.100.100" {
return "steipetacstudio-gateway._openclaw-gw._tcp.openclaw.internal.\n"
}
return ""
@@ -47,4 +74,55 @@ struct WideAreaGatewayDiscoveryTests {
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
#expect(beacon.cliPath == "/Users/steipete/openclaw/src/entry.ts")
}
@Test func `attacker peer cannot become nameserver`() {
let originalWideAreaDomain = getenv("OPENCLAW_WIDE_AREA_DOMAIN").map { String(cString: $0) }
setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1)
defer {
if let originalWideAreaDomain {
setenv("OPENCLAW_WIDE_AREA_DOMAIN", originalWideAreaDomain, 1)
} else {
unsetenv("OPENCLAW_WIDE_AREA_DOMAIN")
}
}
let statusJson = """
{
"Self": { "TailscaleIPs": ["100.64.0.1"] },
"Peer": {
"attacker": { "TailscaleIPs": ["100.64.0.2"] }
}
}
"""
let queriedNameservers = NameserverQueryLog()
let context = WideAreaGatewayDiscovery.DiscoveryContext(
tailscaleStatus: { statusJson },
dig: { args, _ in
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
queriedNameservers.record(nameserver)
let recordType = args.last ?? ""
if recordType == "PTR" {
if nameserver == "@100.64.0.2" {
return "evil._openclaw-gw._tcp.openclaw.internal.\n"
}
return ""
}
if recordType == "SRV" {
return "0 0 443 evil.ts.net."
}
if recordType == "TXT" {
return "\"displayName=Evil\""
}
return ""
})
let beacons = WideAreaGatewayDiscovery.discover(
timeoutSeconds: 2.0,
context: context)
#expect(queriedNameservers.count(matching: "@100.64.0.2") == 0)
#expect(queriedNameservers.count(matching: "@100.100.100.100") == 1)
#expect(beacons.isEmpty)
}
}

View File

@@ -35,6 +35,25 @@ public enum GatewayTLSStore {
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
}
@discardableResult
public static func clearFingerprint(stableID: String) -> Bool {
let removedKeychain = GenericPasswordKeychainStore.delete(
service: self.keychainService,
account: stableID)
self.clearLegacyFingerprint(stableID: stableID)
return removedKeychain
}
@discardableResult
public static func clearAllFingerprints() -> Bool {
let removedKeychain = SecItemDelete([
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.keychainService,
] as CFDictionary)
self.clearAllLegacyFingerprints()
return removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound
}
// MARK: - Migration
/// On first Keychain read for a given stableID, move any legacy UserDefaults
@@ -53,6 +72,18 @@ public enum GatewayTLSStore {
}
defaults.removeObject(forKey: legacyKey)
}
private static func clearLegacyFingerprint(stableID: String) {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
defaults.removeObject(forKey: self.legacyKeyPrefix + stableID)
}
private static func clearAllLegacyFingerprints() {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) {
defaults.removeObject(forKey: key)
}
}
}
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {

View File

@@ -9,6 +9,7 @@ public enum ErrorCode: String, Codable, Sendable {
case notPaired = "NOT_PAIRED"
case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST"
case approvalNotFound = "APPROVAL_NOT_FOUND"
case unavailable = "UNAVAILABLE"
}
@@ -2234,21 +2235,29 @@ public struct AgentSummary: Codable, Sendable {
public let id: String
public let name: String?
public let identity: [String: AnyCodable]?
public let workspace: String?
public let model: [String: AnyCodable]?
public init(
id: String,
name: String?,
identity: [String: AnyCodable]?)
identity: [String: AnyCodable]?,
workspace: String?,
model: [String: AnyCodable]?)
{
self.id = id
self.name = name
self.identity = identity
self.workspace = workspace
self.model = model
}
private enum CodingKeys: String, CodingKey {
case id
case name
case identity
case workspace
case model
}
}
@@ -3434,6 +3443,90 @@ public struct ExecApprovalResolveParams: Codable, Sendable {
}
}
public struct PluginApprovalRequestParams: Codable, Sendable {
public let pluginid: String?
public let title: String
public let description: String
public let severity: String?
public let toolname: String?
public let toolcallid: String?
public let agentid: String?
public let sessionkey: String?
public let turnsourcechannel: String?
public let turnsourceto: String?
public let turnsourceaccountid: String?
public let turnsourcethreadid: AnyCodable?
public let timeoutms: Int?
public let twophase: Bool?
public init(
pluginid: String?,
title: String,
description: String,
severity: String?,
toolname: String?,
toolcallid: String?,
agentid: String?,
sessionkey: String?,
turnsourcechannel: String?,
turnsourceto: String?,
turnsourceaccountid: String?,
turnsourcethreadid: AnyCodable?,
timeoutms: Int?,
twophase: Bool?)
{
self.pluginid = pluginid
self.title = title
self.description = description
self.severity = severity
self.toolname = toolname
self.toolcallid = toolcallid
self.agentid = agentid
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.timeoutms = timeoutms
self.twophase = twophase
}
private enum CodingKeys: String, CodingKey {
case pluginid = "pluginId"
case title
case description
case severity
case toolname = "toolName"
case toolcallid = "toolCallId"
case agentid = "agentId"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
}
public struct PluginApprovalResolveParams: Codable, Sendable {
public let id: String
public let decision: String
public init(
id: String,
decision: String)
{
self.id = id
self.decision = decision
}
private enum CodingKeys: String, CodingKey {
case id
case decision
}
}
public struct DevicePairListParams: Codable, Sendable {}
public struct DevicePairApproveParams: Codable, Sendable {
@@ -3637,6 +3730,10 @@ public struct ChatSendParams: Codable, Sendable {
public let message: String
public let thinking: String?
public let deliver: Bool?
public let originatingchannel: String?
public let originatingto: String?
public let originatingaccountid: String?
public let originatingthreadid: String?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
@@ -3648,6 +3745,10 @@ public struct ChatSendParams: Codable, Sendable {
message: String,
thinking: String?,
deliver: Bool?,
originatingchannel: String?,
originatingto: String?,
originatingaccountid: String?,
originatingthreadid: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
@@ -3658,6 +3759,10 @@ public struct ChatSendParams: Codable, Sendable {
self.message = message
self.thinking = thinking
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
@@ -3670,6 +3775,10 @@ public struct ChatSendParams: Codable, Sendable {
case message
case thinking
case deliver
case originatingchannel = "originatingChannel"
case originatingto = "originatingTo"
case originatingaccountid = "originatingAccountId"
case originatingthreadid = "originatingThreadId"
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -47,6 +47,10 @@
"source": "Quick Start",
"target": "快速开始"
},
{
"source": "Diffs",
"target": "Diffs"
},
{
"source": "Capability Cookbook",
"target": "能力扩展手册"

View File

@@ -44,6 +44,13 @@ Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
## OAuth SecretRef Policy Guard
- SecretRef input is for static credentials only.
- If a profile credential is `type: "oauth"`, SecretRef objects are not supported for that profile credential material.
- If `auth.profiles.<id>.mode` is `"oauth"`, SecretRef-backed `keyRef`/`tokenRef` input for that profile is rejected.
- Violations are hard failures in startup/reload auth resolution paths.
## Legacy-Compatible Messaging
For script compatibility, probe errors keep this first line unchanged:

109
docs/automation/clawflow.md Normal file
View File

@@ -0,0 +1,109 @@
---
summary: "ClawFlow workflow orchestration for background tasks and detached runs"
read_when:
- You want a flow to own one or more detached tasks
- You want to inspect or cancel a background job as a unit
- You want to understand how flows relate to tasks and background work
title: "ClawFlow"
---
# ClawFlow
ClawFlow is the flow layer above [Background Tasks](/automation/tasks). Tasks still track detached work. ClawFlow groups those task runs into a single job, keeps the parent owner context, and gives you a flow-level control surface.
Use ClawFlow when the work is more than a single detached run. A flow can still be one task, but it can also coordinate multiple tasks in a simple linear sequence.
## TL;DR
- Tasks are the execution records.
- ClawFlow is the job-level wrapper above tasks.
- A flow keeps one owner/session context for the whole job.
- Use `openclaw flows list`, `openclaw flows show`, and `openclaw flows cancel` to inspect or manage flows.
## Quick start
```bash
openclaw flows list
openclaw flows show <flow-id-or-owner-session>
openclaw flows cancel <flow-id-or-owner-session>
```
## How it relates to tasks
Background tasks still do the low-level work:
- ACP runs
- subagent runs
- cron executions
- CLI-initiated runs
ClawFlow sits above that ledger:
- it keeps related task runs under one flow id
- it tracks the flow state separately from the individual task state
- it makes blocked or multi-step work easier to inspect from one place
For a single detached run, the flow can be a one-task flow. For more structured work, ClawFlow can keep multiple task runs under the same job.
## Runtime substrate
ClawFlow is the runtime substrate, not a workflow language.
It owns:
- the flow id
- the owner session and return context
- waiting state
- small persisted outputs
- finish, fail, cancel, and blocked state
It does **not** own branching or business logic. Put that in the authoring layer that sits above it:
- Lobster
- acpx
- plain TypeScript helpers
- bundled skills
In practice, authoring layers target a small runtime surface:
- `createFlow(...)`
- `runTaskInFlow(...)`
- `setFlowWaiting(...)`
- `setFlowOutput(...)`
- `appendFlowOutput(...)`
- `emitFlowUpdate(...)`
- `resumeFlow(...)`
- `finishFlow(...)`
- `failFlow(...)`
That keeps flow ownership and return-to-thread behavior in core without forcing a single DSL on top of it.
## Authoring pattern
The intended shape is linear:
1. Create one flow for the job.
2. Run one detached task under that flow.
3. Wait for the child task or outside event.
4. Resume the flow in the caller.
5. Spawn the next child task or finish.
ClawFlow persists the minimal state needed to resume that job: the current step, the task it is waiting on, and a small output bag for handoff between steps.
## CLI surface
The flow CLI is intentionally small:
- `openclaw flows list` shows active and recent flows
- `openclaw flows show <lookup>` shows one flow and its linked tasks
- `openclaw flows cancel <lookup>` cancels the flow and any active child tasks
`flows show` also surfaces the current wait target and any stored output keys, which is often enough to answer "what is this job waiting on?" without digging into every child task.
The lookup token accepts either a flow id or the owner session key.
## Related
- [Background Tasks](/automation/tasks) — detached work ledger
- [CLI: flows](/cli/flows) — flow inspection and control commands
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may create tasks

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