Compare commits

..

2534 Commits

Author SHA1 Message Date
liuxiaopai-ai
32e4558e4f Config: newline-join sandbox setupCommand arrays 2026-03-02 18:10:31 +00:00
bmendonca3
8b27582509 fix(cli): apply --profile before dotenv bootstrap in runCli (#31950)
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
2026-03-02 18:09:45 +00:00
bmendonca3
a6489ab5e9 fix(agents): cap openai-completions tool call ids to provider-safe format (#31947)
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
2026-03-02 18:08:20 +00:00
Peter Steinberger
83c8406f01 refactor(security): split gateway auth suites and share safe write path checks 2026-03-02 18:07:03 +00:00
Peter Steinberger
602f6439bd test(memory): stabilize windows qmd spawn expectation 2026-03-02 18:06:12 +00:00
Peter Steinberger
1c9deeda97 refactor: split webhook ingress and policy guards 2026-03-02 18:02:21 +00:00
Peter Steinberger
fc0d374390 test(perf): drop duplicate gateway config patch validation case 2026-03-02 18:00:11 +00:00
Peter Steinberger
0ebe0480fa test(perf): replace relay fixed sleeps with condition waits 2026-03-02 17:55:47 +00:00
Peter Steinberger
8ae8056622 test(perf): trim telegram webhook artificial delay windows 2026-03-02 17:48:36 +00:00
Peter Steinberger
54382a66b4 test(perf): bypass matrix send queue delay in send tests 2026-03-02 17:46:31 +00:00
Peter Steinberger
d7ae61c412 test(gateway): fix trusted-proxy control-ui auth test origin config 2026-03-02 17:45:45 +00:00
Peter Steinberger
b07589642d test(perf): remove redundant acpx healthy-command case 2026-03-02 17:41:51 +00:00
Peter Steinberger
26b8e6d510 test(perf): avoid cron min-refire delay in auto-run coverage 2026-03-02 17:41:51 +00:00
Peter Steinberger
e339c75d5d style(docs): format faq markdown spacing 2026-03-02 17:38:11 +00:00
Peter Steinberger
7dac9b05dd fix(security): harden zip write race handling 2026-03-02 17:38:11 +00:00
Peter Steinberger
eb35fb745d docs: remove provider recommendation language 2026-03-02 17:33:38 +00:00
Peter Steinberger
b9e820b7ed test(perf): cut exec approval metadata test timeout 2026-03-02 17:33:06 +00:00
Peter Steinberger
aee27d0e38 refactor(security): table-drive wrapper approval pinning tests 2026-03-02 17:30:48 +00:00
Peter Steinberger
34ff873a7e test(perf): trim fixed waits in relay and startup tests 2026-03-02 17:30:33 +00:00
Peter Steinberger
310dd24ce3 test(perf): clean acpx runtime fixtures at suite end 2026-03-02 17:30:33 +00:00
Peter Steinberger
d4bf07d075 refactor(security): unify hardened install and fs write flows 2026-03-02 17:23:29 +00:00
Peter Steinberger
d3e8b17aa6 fix: harden webhook auth-before-body handling 2026-03-02 17:21:09 +00:00
Peter Steinberger
dded569626 fix(security): preserve system.run wrapper approval semantics 2026-03-02 17:20:52 +00:00
Peter Steinberger
104d32bb64 fix(security): unify root-bound write hardening 2026-03-02 17:12:33 +00:00
Peter Steinberger
be3a62c5e0 test(perf): defer delivery queue fixture cleanup to suite end 2026-03-02 17:10:55 +00:00
Hiren Thakore
193ad2f4f0 fix: handle PowerShell execution policy on Windows install (#24794)
* fix: add Arch Linux support to install.sh (GH#8051)

* fix: handle PowerShell execution policy on Windows install (GH#24784)
2026-03-02 11:09:01 -06:00
Dalomeve
a0e11e63fe docs(faq): add Windows exec encoding troubleshooting (#30736)
Co-authored-by: dalomeve <dalomeve@users.noreply.github.com>
2026-03-02 11:08:26 -06:00
Peter Steinberger
07b16d5ad0 fix(security): harden workspace bootstrap boundary reads 2026-03-02 17:07:36 +00:00
Mark L
67b2dde7c5 Docs: add WSL2 boot auto-start guide (#31616) 2026-03-02 11:07:15 -06:00
Glucksberg
7a55a3ca07 fix(install): correct Windows PATH troubleshooting docs (#28102)
* fix(install): correct Windows PATH troubleshooting — no \bin suffix needed (closes #19921)

* fix(docs): apply same PATH fix to FAQ
2026-03-02 11:07:07 -06:00
Peter Steinberger
11562c452a test(perf): avoid unused heartbeat fixture file writes 2026-03-02 17:01:40 +00:00
Val Alexander
eb2e20c994 fix(ui): preserve margin-top: 0 for onboarding mode
- Change margin from -12px -16px -32px to 0 -16px -32px
- Preserves zero top offset required for onboarding mode
- Prevents clipping of top edge/actions area when padding-top: 0
2026-03-02 11:01:27 -06:00
Val Alexander
24a13c05b3 fix(ui): add mobile responsive margins and overflow fallback
- Add margin: 0 for mobile viewports (<=600px, <=400px) to prevent clipping
- Add overflow: hidden fallback for older browsers (Safari <16, Firefox <81)
- Fixes mobile regression where negative margins over-cancel padding

Addresses issue where save button was clipped on mobile due to
hard-coded desktop negative margins not accounting for mobile's
smaller content padding (4px 4px 16px).
2026-03-02 11:01:27 -06:00
SidQin-cyber
20c36f7e84 fix(ui): prevent config page save button from being clipped by overflow
The config-layout used a uniform margin: -16px that did not match the
parent .content padding (12px 16px 32px), causing the right edge of the
actions bar—including the Save button—to extend into the overflow-hidden
region on systems with non-overlay scrollbars (e.g. Ubuntu/GTK).

Changes:
- Match negative margin to actual .content padding (-12px -16px -32px).
- Use overflow: clip instead of overflow: hidden on .config-main so it
  does not create a scroll container that shifts the stacking context.
- Add flex-shrink: 0 and position: relative on .config-actions to
  guarantee the actions bar is never collapsed or layered behind the
  scrollable content area.

Closes #31658
2026-03-02 11:01:27 -06:00
Peter Steinberger
db7a8a6982 test(perf): reuse delivery queue suite temp root 2026-03-02 16:55:18 +00:00
Peter Steinberger
4a80311628 refactor(security): split sandbox media staging and stream safe copies 2026-03-02 16:53:14 +00:00
Peter Steinberger
7a7eee920a refactor(gateway): harden plugin http route contracts 2026-03-02 16:48:00 +00:00
Peter Steinberger
33e76db12a refactor(gateway): scope ws origin fallback metrics to runtime 2026-03-02 16:47:00 +00:00
Peter Steinberger
9a68590385 refactor(logging): extract bounded regex redaction util 2026-03-02 16:47:00 +00:00
Peter Steinberger
031bf0c6c0 refactor(security): split safe-regex parse and bounded matching 2026-03-02 16:47:00 +00:00
Peter Steinberger
8611fd67b5 test(perf): remove duplicate bundled memory slot loader case 2026-03-02 16:46:17 +00:00
Peter Steinberger
14c93d2646 docs(changelog): add skills archive extraction hardening note 2026-03-02 16:45:47 +00:00
Artale
1b462ed174 fix(test): use NTFS junctions and platform guards for symlink tests on Windows (openclaw#28747) thanks @arosstale
Verified:
- pnpm install --frozen-lockfile
- pnpm test src/agents/apply-patch.test.ts src/agents/sandbox/fs-bridge.test.ts src/agents/sandbox/validate-sandbox-security.test.ts src/infra/archive.test.ts

Co-authored-by: arosstale <117890364+arosstale@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:45:19 -06:00
Peter Steinberger
18f8393b6c fix: harden sandbox writes and centralize atomic file writes 2026-03-02 16:45:12 +00:00
Peter Steinberger
14e4575af5 docs(changelog): note gateway and regex hardening 2026-03-02 16:38:03 +00:00
Peter Steinberger
b1592457fa perf(security): bound regex input in filters and redaction 2026-03-02 16:37:45 +00:00
Peter Steinberger
31c7637e0f fix(security): block quantified ambiguous alternation regex 2026-03-02 16:37:45 +00:00
Peter Steinberger
d5ae4b8337 fix(gateway): require local client for loopback origin fallback 2026-03-02 16:37:45 +00:00
Peter Steinberger
0dbb92dd2b fix(security): harden tar archive extraction parity 2026-03-02 16:36:56 +00:00
Peter Steinberger
17ede52a4b fix(security): harden sandbox media staging destination writes 2026-03-02 16:35:08 +00:00
Gustavo Madeira Santana
be65dc8acc docs(diffs): clarify file size limitations 2026-03-02 11:34:12 -05:00
zwffff
8828418111 test(subagent-announce): fix flaky Windows-only test failure (#31298) (openclaw#31370) thanks @zwffff
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check (fails on main baseline issues in extensions/googlechat and extensions/phone-control)
- pnpm test:e2e src/agents/subagent-announce.format.e2e.test.ts

Co-authored-by: zwffff <5809959+zwffff@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:33:07 -06:00
Peter Steinberger
4dd6c7a509 test(perf): avoid redundant root mkdir in hooks install tests 2026-03-02 16:33:00 +00:00
bboyyan
d94de5c4a1 fix(cron): normalize topic-qualified target.to in messaging tool suppress check (#29480)
* fix(cron): pass job.delivery.accountId through to delivery target resolution

* fix(cron): normalize topic-qualified target.to in messaging tool suppress check

When a cron job targets a Telegram forum topic (e.g. delivery.to =
"-1003597428309:topic:462"), delivery.to is stripped to the chatId
only by resolveOutboundTarget. However, the agent's message tool may
pass the full topic-qualified address as its target, causing
matchesMessagingToolDeliveryTarget to fail the equality check and not
suppress the tool send.

Strip the :topic:NNN suffix from target.to before comparing so the
suppress check works correctly for topic-bound cron deliveries.
Without this, the agent's message tool fires separately using the
announce session's accountId (often "default"), hitting 403 when
default bot is not in the multi-account target group.

* fix(cron): remove duplicate accountId keys after rebase

---------

Co-authored-by: jaxpkm <jaxpkm@jaxpkmdeMac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:32:06 -06:00
Glucksberg
09f49cd921 fix(cron): accept delivery mode "none" for sessionTarget="main" (#27431) (#28871) 2026-03-02 10:32:00 -06:00
Ayaan Zaidi
87d05592ea docs(changelog): add telegram dm streaming note (#31824) 2026-03-02 21:59:19 +05:30
Peter Steinberger
d74bc257d8 fix(line): mark webhook route as plugin-authenticated 2026-03-02 16:27:47 +00:00
Ayaan Zaidi
6edb512efa feat(telegram): use sendMessageDraft for private chat streaming (#31824)
* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
2026-03-02 21:56:59 +05:30
Peter Steinberger
c973b053a5 refactor(net): unify proxy env checks and guarded fetch modes 2026-03-02 16:24:26 +00:00
Peter Steinberger
a229ae6c3e chore(lint): add registerHttpHandler usage guard script 2026-03-02 16:24:06 +00:00
Peter Steinberger
2fd8264ab0 refactor(gateway): hard-break plugin wildcard http handlers 2026-03-02 16:24:06 +00:00
Peter Steinberger
b13d48987c refactor(gateway): unify control-ui and plugin webhook routing 2026-03-02 16:18:12 +00:00
Tak Hoffman
21708f58ce fix(exec): resolve PATH key case-insensitively for Windows pathPrepend (#25399) (#31879)
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-03-02 10:14:38 -06:00
Tak Hoffman
1ea42ebe98 fix(tsgo): unblock baseline type errors (#31873) 2026-03-02 10:09:49 -06:00
Peter Steinberger
3e5762c288 fix(security): harden sms.send dangerous-node defaults 2026-03-02 16:06:52 +00:00
SidQin-cyber
c4711a9b69 fix(gateway): let POST requests pass through root-mounted Control UI to plugin handlers
The Control UI handler checked HTTP method before path routing, causing
all POST requests (including plugin webhook endpoints like /bluebubbles-webhook)
to receive 405 Method Not Allowed.  Move the method check after path-based
exclusions so non-GET/HEAD requests reach plugin HTTP handlers.

Closes #31344

Made-with: Cursor
2026-03-02 16:06:48 +00:00
Peter Steinberger
ea204e65a0 fix(browser): fail closed navigation guard with env proxy 2026-03-02 16:06:31 +00:00
Peter Steinberger
14fbd0e6b6 test(perf): reduce timer teardown overhead in cron issue regressions 2026-03-02 16:06:04 +00:00
Peter Steinberger
17c434f2f3 refactor: split browser context/actions and unify CDP timeout policy 2026-03-02 16:02:39 +00:00
Peter Steinberger
19f5d1345c test(perf): cache redact hints and tune guardrail scan concurrency 2026-03-02 16:01:41 +00:00
Peter Steinberger
64c443ac65 docs(changelog): credit sessions_spawn agentId validation fix (#31381) 2026-03-02 15:59:45 +00:00
Peter Steinberger
b28e472fa5 fix(agents): validate sessions_spawn agentId format (#31381) 2026-03-02 15:59:45 +00:00
root
0c6db05cc0 fix(agents): add strict format validation to sessions_spawn for agentId
Implements a strict format validation for the agentId parameter in
sessions_spawn to fully resolve the ghost workspace creation bug reported
in #31311.

This fix introduces a regex format gate at the entry point to
immediately reject malformed agentId strings. This prevents error
messages (e.g., 'Agent not found: xyz') or path traversals from being
mangled by normalizeAgentId into seemingly valid IDs (e.g.,
'agent-not-found--xyz'), which was the root cause of the bug.

The validation is placed before normalization and does not interfere
with existing workflows, including delegating to agents that are
allowlisted but not globally configured.

New, non-redundant tests are added to
sessions-spawn.allowlist.test.ts to cover format validation and
ensure no regressions in allowlist behavior.

Fixes #31311
2026-03-02 15:59:45 +00:00
Liu Yuan
ade46d8ab7 fix(logging): log timestamps use local time instead of UTC (#28434)
* fix(logging): log timestamps use local time instead of UTC

Problem: Log timestamps used UTC, but docs say they should use host local timezone

* test(logging): add test for logger timestamp format

Verify logger uses local time (not UTC) in file logs

* changelog: note logger timestamp local-time fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-02 07:57:03 -08:00
Peter Steinberger
82247f09a7 test(perf): remove redundant module reset in system presence version tests 2026-03-02 15:56:30 +00:00
Peter Steinberger
d01e82d54a test(perf): avoid module reload churn in config guard tests 2026-03-02 15:56:30 +00:00
Peter Steinberger
93b0724025 fix(gateway): fail closed plugin auth path canonicalization 2026-03-02 15:55:32 +00:00
Peter Steinberger
44270c533b docs(changelog): credit sandbox mkdirp boundary fix (#31547) 2026-03-02 15:55:00 +00:00
Peter Steinberger
dec2c9e74d fix(sandbox): allow mkdirp boundary checks on existing directories (#31547) 2026-03-02 15:55:00 +00:00
User
6135eb3353 fix(sandbox): allow mkdirp boundary check on existing directories 2026-03-02 15:55:00 +00:00
Peter Steinberger
345abf0b20 fix: preserve dns pinning for strict web SSRF fetches 2026-03-02 15:54:46 +00:00
Peter Steinberger
a3d2021eea test(cron): stabilize model precedence mocks in bun runs (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
e08ba063d8 test(android): fix GatewaySessionInvokeTest coroutine job typing (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
998d477f5e test: stabilize cross-platform regression suites (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
a49afd25ea fix(secrets): ignore stdin EPIPE from fast-exit exec resolvers 2026-03-02 15:47:21 +00:00
Peter Steinberger
d86c1a67e0 fix(slack): correct typed message event overloads (#31701) 2026-03-02 15:47:21 +00:00
Peter Steinberger
05b84e718b fix(feishu): preserve explicit target routing hints (#31594) (thanks @liuxiaopai-ai) 2026-03-02 15:47:21 +00:00
liuxiaopai-ai
07b419a0e7 Feishu: honor group/dm prefixes in target parsing 2026-03-02 15:47:21 +00:00
Gustavo Madeira Santana
12be9a08fe refactor(diffs): dedupe functions 2026-03-02 10:46:45 -05:00
Peter Steinberger
ee1b147631 fix(zalouser): harden inbound sender id handling 2026-03-02 15:44:07 +00:00
Peter Steinberger
208a9b1ad1 docs(zalouser): document js-native migration and breaking change 2026-03-02 15:44:07 +00:00
Peter Steinberger
0f00110f5d test(zalouser): expand native runtime regression coverage 2026-03-02 15:44:07 +00:00
Peter Steinberger
174f2de447 feat(zalouser): migrate runtime to native zca-js 2026-03-02 15:44:07 +00:00
Peter Steinberger
db3d8d82c1 test(perf): avoid module reset churn in daemon lifecycle tests 2026-03-02 15:43:20 +00:00
Peter Steinberger
3f2848433a test(perf): reuse suite temp-home fixture in config io write tests 2026-03-02 15:43:20 +00:00
Peter Steinberger
663c1858b8 refactor(browser): split server context and unify CDP transport 2026-03-02 15:43:05 +00:00
Peter Steinberger
729ddfd7c8 fix: add zalo webhook account-scope regression assertions (#26121) (thanks @bmendonca3) 2026-03-02 15:38:36 +00:00
bmendonca3
f39882d57e zalo: update pairing-store read assertion to scoped params object 2026-03-02 15:38:36 +00:00
bmendonca3
6b7d3fb011 security(zalo): scope pairing store by account 2026-03-02 15:38:36 +00:00
Peter Steinberger
c63c179278 chore: add changelog note for adapter sendPayload rollout (#30144) (thanks @nohat) 2026-03-02 15:35:47 +00:00
David Friedland
dd3f7d57ee sendPayload: add chunking, empty-payload guard, and tests 2026-03-02 15:35:47 +00:00
David Friedland
47ef180fb7 sendPayload: explicit text fallback in text-only path 2026-03-02 15:35:47 +00:00
David Friedland
ebe54e6903 fix(adapters): restructure sendPayload media loop to avoid uninitialized lastResult 2026-03-02 15:35:47 +00:00
David Friedland
d06ee86292 feat(adapters): add sendPayload to batch-d adapters 2026-03-02 15:35:47 +00:00
Peter Steinberger
f1cab9c5e5 fix: stabilize zalouser account-scope regression hook (#26672) (thanks @bmendonca3) 2026-03-02 15:34:17 +00:00
bmendonca3
f4c3e483fe zalouser: update account-scope test for scoped store API 2026-03-02 15:34:17 +00:00
bmendonca3
6aa20e91d9 security(zalouser): scope pairing-store auth to accountId 2026-03-02 15:34:17 +00:00
Evgeny Zislis
4b4ea5df8b feat(cron): add failure destination support to failed cron jobs (#31059)
* feat(cron): add failure destination support with webhook mode and bestEffort handling

Extends PR #24789 failure alerts with features from PR #29145:
- Add webhook delivery mode for failure alerts (mode: 'webhook')
- Add accountId support for multi-account channel configurations
- Add bestEffort handling to skip alerts when job has bestEffort=true
- Add separate failureDestination config (global + per-job in delivery)
- Add duplicate prevention (prevents sending to same as primary delivery)
- Add CLI flags: --failure-alert-mode, --failure-alert-account-id
- Add UI fields for new options in web cron editor

* fix(cron): merge failureAlert mode/accountId and preserve failureDestination on updates

- Fix mergeCronFailureAlert to merge mode and accountId fields
- Fix mergeCronDelivery to preserve failureDestination on updates
- Fix isSameDeliveryTarget to use 'announce' as default instead of 'none'
  to properly detect duplicates when delivery.mode is undefined

* fix(cron): validate webhook mode requires URL in resolveFailureDestination

When mode is 'webhook' but no 'to' URL is provided, return null
instead of creating an invalid plan that silently fails later.

* fix(cron): fail closed on webhook mode without URL and make failureDestination fields clearable

- sendCronFailureAlert: fail closed when mode is webhook but URL is missing
- mergeCronDelivery: use per-key presence checks so callers can clear
  nested failureDestination fields via cron.update

Note: protocol:check shows missing internalEvents in Swift models - this is
a pre-existing issue unrelated to these changes (upstream sync needed).

* fix(cron): use separate schema for failureDestination and fix type cast

- Create CronFailureDestinationSchema excluding after/cooldownMs fields
- Fix type cast in sendFailureNotificationAnnounce to use CronMessageChannel

* fix(cron): merge global failureDestination with partial job overrides

When job has partial failureDestination config, fall back to global
config for unset fields instead of treating it as a full override.

* fix(cron): avoid forcing announce mode and clear inherited to on mode change

- UI: only include mode in patch if explicitly set to non-default
- delivery.ts: clear inherited 'to' when job overrides mode, since URL
  semantics differ between announce and webhook modes

* fix(cron): preserve explicit to on mode override and always include mode in UI patches

- delivery.ts: preserve job-level explicit 'to' when overriding mode
- UI: always include mode in failureAlert patch so users can switch between announce/webhook

* fix(cron): allow clearing accountId and treat undefined global mode as announce

- UI: always include accountId in patch so users can clear it
- delivery.ts: treat undefined global mode as announce when comparing for clearing inherited 'to'

* Cron: harden failure destination routing and add regression coverage

* Cron: resolve failure destination review feedback

* Cron: drop unrelated timeout assertions from conflict resolution

* Cron: format cron CLI regression test

* Cron: align gateway cron test mock types

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 09:27:41 -06:00
Peter Steinberger
a905b6dabc test(perf): merge duplicate one-shot retry regression paths 2026-03-02 15:23:58 +00:00
Peter Steinberger
44c50d9a73 fix(types): tighten shared helper typing contracts 2026-03-02 15:21:19 +00:00
Peter Steinberger
ed21b63bb8 refactor(plugin-sdk): share auth, routing, and stream/account helpers 2026-03-02 15:21:19 +00:00
Peter Steinberger
e9dd6121f2 refactor(core): dedupe embedding imports and env parsing 2026-03-02 15:21:19 +00:00
Peter Steinberger
dcf8308c8f refactor(ui): share channel config extras and hint types 2026-03-02 15:21:19 +00:00
Peter Steinberger
d212721df1 test(perf): merge forum-topic direct-delivery scenarios 2026-03-02 15:17:28 +00:00
Peter Steinberger
a469d00345 test(perf): reuse cron heartbeat delivery temp homes per suite 2026-03-02 15:14:17 +00:00
Peter Steinberger
3fb0ab7435 test(perf): tighten cron issue-regression timeout windows 2026-03-02 15:11:14 +00:00
Peter Steinberger
64ac790aa8 test(perf): reuse temp-home root in cron announce delivery suite 2026-03-02 15:08:35 +00:00
Felix Lu
f1cd3ea531 fix(app:macos): 【 OpenClaw ⇄ clawdbot 】- Peekaboo Bridge discovery after the OpenClaw rename (#6033)
* fix(mac): keep OpenClaw bridge socket and harden legacy symlink

* fix(mac): add clawdis legacy Peekaboo bridge symlink

* macos: include moltbot in PeekabooBridge legacy socket paths

* changelog: note peekaboo legacy socket compatibility paths

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-02 07:00:30 -08:00
Peter Steinberger
c5f1cf3c3b test(perf): reuse isolated-agent temp home root per suite 2026-03-02 15:00:08 +00:00
Peter Steinberger
87bd6226bd test(perf): merge overlapping preaction hook scenarios 2026-03-02 14:52:38 +00:00
Robin Waslander
9f98d2766a fix(logs): respect TZ env var for timestamp display, fix Windows timezone (#21859) 2026-03-02 08:44:37 -06:00
StingNing
944abe0a6c fix(security): recognize localized Windows SYSTEM account in ACL audit (#29698)
* fix(security): recognize localized Windows SYSTEM account in ACL audit

On non-English Windows (e.g. French "AUTORITE NT\Système"), the security
audit falsely reports fs.config.perms_writable because the localized
SYSTEM account name is not recognized as trusted.

Changes:
- Add common localized SYSTEM principal names (French, German, Spanish,
  Portuguese) to TRUSTED_BASE
- Add diacritics-stripping fallback in classifyPrincipal for unhandled
  locales
- Use well-known SID *S-1-5-18 in icacls reset commands instead of
  hardcoded "SYSTEM" string for locale independence

Fixes #29681

* style: format windows acl files

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:38:56 -06:00
Peter Steinberger
dbc78243f4 refactor(scripts): share guard runners and paged select UI 2026-03-02 14:36:41 +00:00
Peter Steinberger
e41f9998f7 refactor(test): extract shared gateway hook and vitest scoped config helpers 2026-03-02 14:36:41 +00:00
Peter Steinberger
741e74972b refactor(plugin-sdk): share boolean action param parsing 2026-03-02 14:36:41 +00:00
Peter Steinberger
693f61404d refactor(shared): centralize assistant identity and usage timeseries types 2026-03-02 14:36:41 +00:00
Peter Steinberger
3efd224ec6 refactor(commands): dedupe session target resolution and fs tool test setup 2026-03-02 14:36:41 +00:00
Peter Steinberger
b85facfb5d refactor(android): share node JSON param parsing helpers 2026-03-02 14:36:41 +00:00
Ajay Elika
e23b6fb2ba fix(gateway): add Windows-compatible port detection using netstat fallback (openclaw#29239) thanks @ajay99511
Verified:
- pnpm vitest src/cli/program.force.test.ts
- pnpm check
- pnpm build

Co-authored-by: ajay99511 <73169130+ajay99511@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:33:59 -06:00
tda
d145518f94 fix(cli): wait for process exit before restarting gateway on Windows (openclaw#27913) thanks @tda1017
Verified:
- pnpm vitest src/cli/update-cli/restart-helper.test.ts
- pnpm check
- pnpm build

Co-authored-by: tda1017 <95275462+tda1017@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:31:03 -06:00
Peter Steinberger
cd18472405 test(perf): trim redundant cron regression setup coverage 2026-03-02 14:25:49 +00:00
Tak Hoffman
2a11a20fe2 test(windows): stabilize exec wrapper mock assertions (#31771) 2026-03-02 08:24:49 -06:00
Peter Steinberger
2a2a9902d9 test(perf): merge isolated-agent model precedence cases 2026-03-02 14:24:32 +00:00
Peter Steinberger
5561a6b659 test(perf): dedupe isolated-agent delivery announce cases 2026-03-02 14:24:32 +00:00
Peter Machona
c2d41dc473 fix(daemon): recover Windows restarts from unknown stale listeners (openclaw#24734) thanks @chilu18
Verified:
- pnpm vitest src/cli/daemon-cli/restart-health.test.ts src/cli/gateway-cli.coverage.test.ts
- pnpm oxfmt --check src/cli/daemon-cli/restart-health.ts src/cli/daemon-cli/restart-health.test.ts
- pnpm check (fails on unrelated repo baseline tsgo errors in extensions/* and src/process/exec.windows.test.ts)

Co-authored-by: chilu18 <7957943+chilu18@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:24:25 -06:00
Peter Steinberger
a05b8f47b1 test(perf): tighten cron regression timeout windows 2026-03-02 14:20:31 +00:00
Peter Steinberger
7d600ff4e2 test(perf): dedupe plugin validation scenarios 2026-03-02 14:20:21 +00:00
Peter Steinberger
38bdb0d271 test(perf): prune redundant preaction command-path cases 2026-03-02 14:14:02 +00:00
Peter Steinberger
32475448eb test(perf): trim ios team-id fixture setup 2026-03-02 14:12:26 +00:00
Fologan
8421b2e848 fix(gateway): avoid stale running status from Windows Scheduled Task (openclaw#19504) thanks @Fologan
Verified:
- pnpm vitest src/daemon/schtasks.test.ts
- pnpm check
- pnpm build

Co-authored-by: Fologan <164580328+Fologan@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:12:24 -06:00
Peter Steinberger
f2468feb86 test(perf): use shell resolver fixture in secrets audit 2026-03-02 14:10:53 +00:00
Tak Hoffman
1fe0f848df fix(slack): type message.channels/group handlers (#31758) 2026-03-02 08:09:49 -06:00
Peter Steinberger
98e5851d8a test(perf): collapse overlapping preaction scenarios 2026-03-02 14:07:06 +00:00
Tak Hoffman
cd653c55d7 windows: unify non-core spawn handling across acp qmd and docker (openclaw#31750) thanks @Takhoffman
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check (fails on pre-existing unrelated src/slack/monitor/events/messages.ts typing errors)
- pnpm vitest run src/acp/client.test.ts src/memory/qmd-manager.test.ts src/agents/sandbox/docker.execDockerRaw.enoent.test.ts src/agents/sandbox/docker.windows.test.ts extensions/acpx/src/runtime-internals/process.test.ts

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:05:39 -06:00
Peter Steinberger
32c7242974 test(perf): simplify ios team-id fixtures 2026-03-02 14:05:08 +00:00
Peter Steinberger
534f436d4e test(perf): reduce repeated cli program setup overhead 2026-03-02 14:02:47 +00:00
Peter Steinberger
234e07fcc0 refactor(process): extract command env resolution helper 2026-03-02 14:02:47 +00:00
Peter Steinberger
9eb70d2725 fix: add proxy-bypass regression + changelog (#31469) (thanks @widingmarcus-cyber) 2026-03-02 13:56:30 +00:00
Marcus Widing
2bec80cd97 fix: preserve user-configured NO_PROXY when loopback already covered
Only restore env vars when we actually modified them (noProxyDidModify
flag). Prevents silently deleting a user's NO_PROXY that already
contains loopback entries. Added regression test.
2026-03-02 13:56:30 +00:00
Marcus Widing
dd8c76110f fix: remove isFirst guard from NO_PROXY restore, add reverse-exit test
Fix Greptile review: when call A exits before call B, the isFirst flag
on B is false, so the restore condition (refCount===0 && isFirst) was
never true and NO_PROXY leaked permanently.

Remove '&& isFirst' so any last exiter (refCount===0) restores the
original env vars. Added explicit reverse-exit-order regression test.
2026-03-02 13:56:30 +00:00
Marcus Widing
158709ff62 fix: make withNoProxyForLocalhost reentrant-safe, fix [::1] check
Address Greptile review feedback:
- Replace snapshot/restore pattern with reference counter to prevent
  permanent NO_PROXY env-var leak under concurrent async calls
- Include [::1] in the alreadyCoversLocalhost guard
- Add concurrency regression test
2026-03-02 13:56:30 +00:00
Marcus Widing
c96234b51d fix: bypass proxy for CDP localhost connections (#31219)
When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
CDP connections to localhost/127.0.0.1 can be incorrectly routed through
the proxy (e.g. via global-agent or undici proxy dispatcher), causing
browser control to fail.

Fix:
- New cdp-proxy-bypass module with utilities for direct localhost connections
- WebSocket (ws) CDP connections: pass explicit http.Agent to bypass any
  global proxy agent patching
- fetch-based CDP probes: wrap in withNoProxyForLocalhost() to temporarily
  set NO_PROXY for the duration of the call
- Playwright connectOverCDP: wrap in withNoProxyForLocalhost() since
  Playwright reads env vars internally
- 13 new tests covering getDirectAgentForCdp, hasProxyEnv, and
  withNoProxyForLocalhost (env save/restore, error recovery)
2026-03-02 13:56:30 +00:00
Peter Steinberger
1184d39e1d fix: extend managed-tab cap regressions + changelog (#29724) (thanks @pandego) 2026-03-02 13:55:09 +00:00
pandego
e303b356ba fix(browser): detach tab-cap listing from openTab return path 2026-03-02 13:55:09 +00:00
pandego
22ec577d80 fix(browser): require managed runtime ownership for tab cap cleanup 2026-03-02 13:55:09 +00:00
pandego
9b938f2bf6 fix(browser): skip tab cap cleanup for attach-only profiles 2026-03-02 13:55:09 +00:00
pandego
c7bf54b914 fix(browser): scope tab cap to local profile and detach cleanup closes 2026-03-02 13:55:09 +00:00
pandego
c350dc8a7b fix(browser): keep tab-cap cleanup best-effort on list errors 2026-03-02 13:55:09 +00:00
pandego
b47dc73b70 fix(browser): cap managed profile tabs to prevent renderer buildup 2026-03-02 13:55:09 +00:00
Keenan
050e928985 [codex] Fix main-session web UI reply routing to Telegram (openclaw#29328) thanks @BeeSting50
Verified:
- pnpm test src/auto-reply/reply/dispatch-from-config.test.ts src/gateway/server-methods/chat.directive-tags.test.ts
- pnpm exec oxfmt --check src/auto-reply/reply/dispatch-from-config.test.ts src/gateway/server-methods/chat.directive-tags.test.ts src/auto-reply/reply/dispatch-from-config.ts src/gateway/server-methods/chat.ts CHANGELOG.md
- CI note: non-required check "check" failed on unrelated src/slack/monitor/events/messages.ts TS errors outside this PR scope.

Co-authored-by: BeeSting50 <85285887+BeeSting50@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:54:16 -06:00
Peter Steinberger
99ee26d534 fix: add timeout cleanup regression for browser CDP readiness (#29538) (thanks @AaronWander) 2026-03-02 13:53:21 +00:00
AaronWander
8bccb0032a fix(browser): bound post-launch CDP wait by elapsed time (#21149) 2026-03-02 13:53:21 +00:00
AaronWander
d06cc77f38 fix(browser): wait for CDP readiness after start (#21149) 2026-03-02 13:53:21 +00:00
Peter Steinberger
0d620a56e2 test(refactor): reuse shared program setup in preaction tests 2026-03-02 13:53:10 +00:00
Peter Steinberger
09748ab109 test(perf): speed up supervisor and exec process tests 2026-03-02 13:53:10 +00:00
Peter Steinberger
2d8b8a17ab test(android): dedupe node and gateway invoke tests 2026-03-02 13:52:36 +00:00
Rain120
6ea6aca5bd fix(ui): the header has been hidden by content in the config page 2026-03-02 07:52:26 -06:00
Sid
7b5a410b83 fix(node-host): decode Windows exec output with active code page (openclaw#30652) thanks @Sid-Qin
Verified:
- pnpm vitest run src/node-host/invoke.sanitize-env.test.ts src/node-host/invoke-system-run.test.ts

Co-authored-by: Sid-Qin <53659198+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:50:17 -06:00
icesword0760
6e008e93be Process: fix Windows .cmd spawn EINVAL (openclaw#29759) thanks @icesword0760
Verified:
- pnpm vitest run src/process/exec.test.ts src/process/exec.windows.test.ts

Co-authored-by: icesword0760 <123886211+icesword0760@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:49:54 -06:00
SidQin-cyber
732c4f3921 fix(browser): retry chrome act when target tab is stale
When a Chrome relay targetId becomes stale between snapshot and action,
the browser tool now retries once without targetId so the relay falls
back to the currently attached tab.

Drop the unknown recovered field from the test mock return value
to satisfy tsc strict checking against BrowserActResponse.
2026-03-02 13:49:33 +00:00
leotwang
910c654807 test(config): add schema regression tests for browser.extraArgs 2026-03-02 13:47:59 +00:00
leotwang
925117d277 config: add extraArgs to browser zod schema 2026-03-02 13:47:59 +00:00
Yasunori Morishima(盛島康徳)
be8930d6f9 fix: clear stale runningAtMs in cron.run() before already-running check (#17949)
Add recomputeNextRunsForMaintenance() call in run() so that stale
runningAtMs markers (from a crashed Phase-1 persist) are cleared by the
existing normalizeJobTickState logic before the already-running guard.

Without this, a manual cron.run() could be blocked for up to
STUCK_RUN_MS (2 hours) even though no job was actually running.

Fixes #17554

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:47:36 -06:00
Peter Steinberger
60b8d645de test(perf): standardize loader fixtures to cjs 2026-03-02 13:43:55 +00:00
Mark L
097ad88f9d fix(feishu): tolerate missing webhook defaults in older plugin-sdk (openclaw#31639) thanks @liuxiaopai-ai
Verified:
- pnpm test extensions/feishu/src/monitor.state.defaults.test.ts
- pnpm exec vitest run extensions/feishu/src/monitor.state.defaults.test.ts
- pnpm exec oxfmt --check extensions/feishu/src/monitor.state.ts extensions/feishu/src/monitor.state.defaults.test.ts CHANGELOG.md
- CI note: non-required check "check" failed on unrelated  TS errors outside this PR scope.

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:42:16 -06:00
Kate Chapman
6df8bd9741 fix(cron): wrap computeJobNextRunAtMs in try-catch inside applyJobResult (#30905)
* fix(cron): wrap computeJobNextRunAtMs in try-catch inside applyJobResult

Without this guard, if the croner library throws during schedule
computation (timezone/expression edge cases), the exception propagates
out of applyJobResult and the entire state update is lost — runningAtMs
never clears, lastRunAtMs never advances, nextRunAtMs never recomputes.
After STUCK_RUN_MS (2h), stuck detection clears runningAtMs and the job
re-fires, creating a ~2h repeat cycle instead of the intended schedule.

The sibling function recomputeJobNextRunAtMs in jobs.ts already wraps
computeJobNextRunAtMs in try-catch; this was an oversight in the
applyJobResult call sites.

Changes:
- Error-backoff path: catch and fall back to backoff-only schedule
- Success path: catch and fall through to the MIN_REFIRE_GAP_MS safety net
- applyOutcomeToStoredJob: log a warning when job not found after forceReload

* fix(cron): use recordScheduleComputeError in applyJobResult catch blocks

Address review feedback: the original catch blocks only logged a warning,
which meant a persistent computeJobNextRunAtMs throw would cause a
MIN_REFIRE_GAP_MS (2s) hot loop on cron-kind jobs.

Now both catch blocks call recordScheduleComputeError (exported from
jobs.ts), which tracks consecutive schedule errors and auto-disables the
job after 3 failures — matching the existing behavior in
recomputeJobNextRunAtMs.

* test(cron): cover applyJobResult schedule-throw fallback paths

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:38:33 -06:00
Andrey
21e19e42a3 fix(cron): skip isError payloads when picking summary/delivery content (#21454)
* fix(cron): skip isError payloads when picking summary/delivery content

buildEmbeddedRunPayloads appends isError warnings as the last payload.
Three functions in helpers.ts iterate last-to-first and pick the error
over real agent output. Use two-pass selection: prefer non-error payloads,
fall back to error-only when no real content exists.

Fixes: pickSummaryFromPayloads, pickLastNonEmptyTextFromPayloads,
pickLastDeliverablePayload — all now accept and filter isError.

* Changelog: note cron payload isError filtering (#21454)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:38:05 -06:00
Peter Steinberger
2c192a3795 test(perf): reduce cron overlap timer advance slack 2026-03-02 13:37:26 +00:00
Peter Steinberger
02bd7a2249 test(perf): use CJS fixtures in plugin loader tests 2026-03-02 13:36:17 +00:00
Jared Grimes
aa5d173bec fix(feishu): prevent duplicate delivery when message tool uses generic provider (openclaw#31538) thanks @jlgrimes
Verified:
- pnpm exec vitest run src/auto-reply/reply/agent-runner-payloads.test.ts src/auto-reply/reply/followup-runner.test.ts
- pnpm check (fails on unrelated baseline type errors outside PR scope)

Co-authored-by: jlgrimes <8084595+jlgrimes@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:35:58 -06:00
liuxiaopai-ai
06306501ab Slack: register typed channel/group message event handlers 2026-03-02 13:32:54 +00:00
Yuzuru Suzuki
6513c42d2d fix(cron): treat announce delivery failure as ok when execution succeeded (#31082)
* cron: treat announce delivery failure as ok when agent execution succeeded

* fix: set delivered:false and error on announce delivery failure paths

* Changelog: note cron announce delivery status handling (#31082)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:27:57 -06:00
Peter Steinberger
16e85360a1 perf(cli): cache preaction lazy module imports 2026-03-02 13:26:54 +00:00
Peter Steinberger
4d31c29a19 test(perf): skip shell profile loading in ios team-id script tests 2026-03-02 13:25:49 +00:00
Peter Steinberger
79cb5e2c9b test(perf): trim cron regression timeout windows 2026-03-02 13:25:49 +00:00
kleebaker
b40d5817a2 fix(cron): avoid 30s timeout for cron run --expect-final (#29942)
* fix(cron): use longer default timeout for cron run --expect-final

* test(cron-cli): stabilize cron run timeout assertions with explicit run exits

---------

Co-authored-by: Kelly Baker <kelly@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:24:42 -06:00
Tak Hoffman
254bb7ceee ui(cron): add advanced controls for run-if-due and routing (#31244)
* ui(cron): add advanced run controls and routing fields

* ui(cron): gate delivery account id to announce mode

* ui(cron): allow clearing delivery account id in editor

* cron: persist payload lightContext updates

* tests(cron): fix payload lightContext assertion typing
2026-03-02 07:24:33 -06:00
cygaar
127217612c fix(CI/CD): use path.resolve in expandHomePrefix test for Windows compat (#30961)
Merged via squash.

Prepared head SHA: 26bc118517
Co-authored-by: cygaar <97691933+cygaar@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-02 14:18:11 +01:00
Peter Steinberger
0b762e9a02 fix(android): import remember for pending tools bubble 2026-03-02 13:11:08 +00:00
Peter Steinberger
cb9bce902e fix(infra): accept cross-realm promises in boundary traversal 2026-03-02 13:00:21 +00:00
Peter Steinberger
848ade07da test(cli): fix gateway coverage mock signature 2026-03-02 13:00:21 +00:00
Peter Steinberger
a9d572394e test(perf): tighten exec timeout slack in non-flaky cases 2026-03-02 12:58:00 +00:00
Peter Steinberger
b02b94673f refactor: dedupe runtime and helper flows 2026-03-02 12:55:47 +00:00
Peter Steinberger
5d3f066bbd test(perf): reduce boundary-path fuzz setup churn 2026-03-02 12:54:59 +00:00
Peter Steinberger
6adc93cc92 test(perf): skip scheduler startup in cron delivery-plan tests 2026-03-02 12:54:53 +00:00
Peter Steinberger
e99928f3f1 test(perf): use git ls-files fast path for guardrail source scan 2026-03-02 12:42:02 +00:00
Peter Steinberger
afda085b39 test(perf): disable scheduler startup in manual-only cron regressions 2026-03-02 12:41:56 +00:00
Peter Steinberger
3980c315d1 test(perf): avoid real node startup in pre-commit hook integration 2026-03-02 12:41:51 +00:00
Peter Steinberger
7b38e8231e test(perf): stub expensive cli coverage integration paths 2026-03-02 12:41:45 +00:00
Peter Steinberger
f94d6fb1f1 test(perf): stub pre-commit helpers in hook integration test 2026-03-02 12:27:37 +00:00
Peter Steinberger
5fed91e624 test(perf): avoid real python startup in ios team-id integration case 2026-03-02 12:26:38 +00:00
Peter Steinberger
ba3957ad77 test(perf): bypass daemon install token-generation path in coverage test 2026-03-02 12:24:03 +00:00
Peter Steinberger
916b0e6609 test(perf): tighten cron regression timeout constants 2026-03-02 12:21:35 +00:00
Peter Steinberger
099b11fc7d test(perf): align media auto-detect no-key mock with scenario 2026-03-02 12:20:51 +00:00
Peter Steinberger
f7b8e4be27 test(fix): stabilize exec no-output heartbeat timing case 2026-03-02 12:18:27 +00:00
Peter Steinberger
2cda78a0b0 test(perf): stub docker probes in filesystem audit cases 2026-03-02 12:18:27 +00:00
Peter Steinberger
87316e07d8 refactor(macos): share pairing and ui dedupe utilities 2026-03-02 12:13:45 +00:00
Peter Steinberger
d85d3c88d5 refactor(agents): centralize tool display definitions 2026-03-02 12:13:45 +00:00
Peter Steinberger
d977af5853 refactor(diffs): share artifact detail and screenshot test helpers 2026-03-02 12:13:45 +00:00
Peter Steinberger
7533015532 refactor(android): extract shared dedupe helpers for node and chat 2026-03-02 12:13:45 +00:00
Peter Steinberger
f01862bce2 test(perf): clear concurrent-start timeout handle in cron regression test 2026-03-02 12:07:38 +00:00
Peter Steinberger
8e0ca219a4 test(perf): precreate plugin config validation fixtures 2026-03-02 12:06:48 +00:00
Peter Steinberger
bdfd3bae6f test(perf): reuse cli programs in coverage tests 2026-03-02 12:00:28 +00:00
Peter Steinberger
adf2ef88c6 test(perf): simplify temp-path guard scan loop 2026-03-02 11:59:24 +00:00
Peter Steinberger
d95bc10425 test(perf): streamline deep code-safety audit assertions 2026-03-02 11:58:49 +00:00
Peter Steinberger
d9ff3bf1af test(perf): tighten process exec and supervisor timing fixtures 2026-03-02 11:56:57 +00:00
Peter Steinberger
2b855704da test(perf): remove redundant ios team-id script invocation 2026-03-02 11:55:35 +00:00
Peter Steinberger
c80a332def test(perf): cut cron retry waits and tighten tmp guard prefilter 2026-03-02 11:54:26 +00:00
Peter Steinberger
d9a8d3853d test(perf): trim qmd manager fixture setup overhead 2026-03-02 11:54:21 +00:00
Peter Steinberger
94e480f64a test(refactor): dedupe preaction command coverage 2026-03-02 11:41:40 +00:00
Peter Steinberger
735216f7e4 test(perf): reduce security audit and guardrail overhead 2026-03-02 11:41:33 +00:00
Peter Steinberger
316875582a test(perf): speed up pre-commit integration setup 2026-03-02 11:36:16 +00:00
Peter Steinberger
43bffe7bdc test(perf): cache plugin fixtures and streamline shell tests 2026-03-02 11:35:13 +00:00
Peter Steinberger
cf67e374c0 refactor(macos): dedupe UI, pairing, and runtime helpers 2026-03-02 11:32:20 +00:00
Peter Steinberger
cd011897d0 refactor(ios): dedupe status, gateway, and service flows 2026-03-02 11:32:20 +00:00
Peter Steinberger
2ca5722221 refactor(shared): dedupe common OpenClawKit helpers 2026-03-02 11:32:20 +00:00
Peter Steinberger
3dd01c3361 test(perf): reuse shared temp root in plugin install tests 2026-03-02 11:27:04 +00:00
Peter Steinberger
79b649a25e test: fix signal-listener typing in exec bridge test 2026-03-02 11:22:26 +00:00
Peter Steinberger
0c2d85529a test(refactor): dedupe cli and ios script scenarios 2026-03-02 11:16:33 +00:00
Peter Steinberger
1b98879295 test(perf): reduce guardrail and media test overhead 2026-03-02 11:16:29 +00:00
Peter Steinberger
bff785aecc test(perf): tighten process test timeouts and fs setup 2026-03-02 11:16:24 +00:00
Peter Steinberger
4dcb16d696 ci: fix install smoke docker helper path 2026-03-02 11:01:56 +00:00
Peter Steinberger
96ef6ea3cf test(perf): dedupe setup in cli/security script suites 2026-03-02 10:53:21 +00:00
Peter Steinberger
4a8ada662e test(perf): cache media fixtures and trim timeout waits 2026-03-02 10:52:58 +00:00
Peter Steinberger
8a1465c314 test(perf): trim timer-heavy suites and guardrail scanning 2026-03-02 10:28:39 +00:00
Peter Steinberger
f5a265a51a test(sessions): normalize cross-agent path assertions 2026-03-02 10:08:52 +00:00
Peter Steinberger
033c731f19 fix(ci): annotate feishu hoisted mock type 2026-03-02 09:59:16 +00:00
Peter Steinberger
c1a46301b6 fix(ci): align strict nullable typing across channels and ui 2026-03-02 09:56:14 +00:00
Peter Steinberger
fc692d82fd refactor(tests): dedupe macos ipc smoke setup blocks 2026-03-02 09:55:46 +00:00
Peter Steinberger
8553d22428 refactor(tests): dedupe ios gateway and deeplink fixtures 2026-03-02 09:55:46 +00:00
Peter Steinberger
7d44b753ff refactor(tests): dedupe openclawkit chat test helpers 2026-03-02 09:55:46 +00:00
Peter Steinberger
04030ddf68 test(runtime): trim timer-heavy regression suites 2026-03-02 09:47:29 +00:00
Peter Steinberger
fd4d157e45 test(config): reuse fixtures for faster validation 2026-03-02 09:47:29 +00:00
Peter Steinberger
fcb956a0a2 test(cli): reduce update/program suite overhead 2026-03-02 09:46:27 +00:00
Peter Steinberger
500883775b refactor(tests): dedupe ios defaults and setup-code helpers 2026-03-02 09:39:45 +00:00
Peter Steinberger
fd7774a79e refactor(tests): dedupe swift gateway and chat fixtures 2026-03-02 09:39:45 +00:00
Gustavo Madeira Santana
5f49a5da3c Diffs: extend image quality configs and add PDF as a format option (#31342)
Merged via squash.

Prepared head SHA: cc12097851
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-02 04:38:50 -05:00
Peter Steinberger
756f9c9fef refactor(scripts): dedupe installer CLI verification 2026-03-02 08:59:33 +00:00
Peter Steinberger
ad8d766f65 refactor(extensions): dedupe channel config, onboarding, and monitors 2026-03-02 08:54:20 +00:00
Peter Steinberger
d358b3ac88 refactor(core): extract shared usage, auth, and display helpers 2026-03-02 08:54:20 +00:00
Peter Steinberger
e427826fcf refactor(ui): dedupe state, views, and usage helpers 2026-03-02 08:54:20 +00:00
Peter Steinberger
00a2456b72 refactor(scripts): dedupe guard checks and smoke helpers 2026-03-02 08:54:20 +00:00
Vincent Koc
5d53b61d9e fix(browser): honor profile attachOnly for loopback CDP (#31429)
* config(browser): allow profile attachOnly field

* config(schema): accept profile attachOnly

* browser(config): resolve per-profile attachOnly

* browser(runtime): honor profile attachOnly checks

* browser(routes): expose profile attachOnly in status

* config(labels): add browser profile attachOnly label

* config(help): document browser profile attachOnly

* test(config): cover profile attachOnly resolution

* test(browser): cover profile attachOnly runtime path

* test(config): include profile attachOnly help target

* changelog: note profile attachOnly override

* browser(runtime): prioritize attachOnly over loopback ownership error

* test(browser): cover attachOnly ws-failure ownership path
2026-03-02 00:49:57 -08:00
Vincent Koc
29c3ce9454 [AI-assisted] test: fix typing and test fixture issues (#31444)
* test: fix typing and test fixture issues

* Fix type-test harness issues from session routing and mock typing

* Add routing regression test for session.mainKey precedence
2026-03-02 00:41:21 -08:00
Gustavo Madeira Santana
1443bb9a84 chore(tsgo/lint): fix CI errors 2026-03-02 03:03:11 -05:00
Vincent Koc
22be0c5801 fix(browser): support configurable CDP auto-port range start (#31352)
* config(browser): add cdpPortRangeStart type

* config(schema): validate browser.cdpPortRangeStart

* config(labels): add browser.cdpPortRangeStart label

* config(help): document browser.cdpPortRangeStart

* browser(config): resolve custom cdp port range start

* browser(profiles): allocate ports from resolved CDP range

* test(browser): cover cdpPortRangeStart config behavior

* test(browser): cover cdpPortRangeStart profile allocation

* test(browser): include CDP range fields in remote tab harness

* test(browser): include CDP range fields in ensure-tab harness

* test(browser): include CDP range fields in bridge auth config

* build(browser): add resolved CDP range metadata

* fix(browser): fallback CDP port allocation to derived range

* test(browser): cover missing resolved CDP range fallback

* fix(browser): remove duplicate resolved CDP range fields

* fix(agents): provide resolved CDP range in sandbox browser config

* chore(browser): format sandbox bridge resolved config

* chore(browser): reformat sandbox imports to satisfy oxfmt
2026-03-01 23:50:50 -08:00
Vincent Koc
c6e5026edf Docs: sort provider lists A-Z 2026-03-01 23:42:55 -08:00
Vincent Koc
7e8118a93e Docs: sort built-in tools links A-Z 2026-03-01 23:41:39 -08:00
Vincent Koc
c977ac8d26 Docs: sort supported channels A-Z 2026-03-01 23:40:51 -08:00
Vincent Koc
ee22a01ec9 Docs: remove dead concepts/sessions alias 2026-03-01 23:40:09 -08:00
Vincent Koc
abe0edaba7 Docs: sort channels list by name across locales 2026-03-01 23:38:55 -08:00
Vincent Koc
a969df4c00 Docs: remove quickstart from first steps nav 2026-03-01 23:36:38 -08:00
Vincent Koc
fbc1585b3f fix(pairing): handle missing accountId in allowFrom reads (#31369)
* pairing: honor default account in allowFrom read when accountId omitted

* changelog: credit pairing allowFrom fallback fix
2026-03-01 23:24:33 -08:00
Vincent Koc
e055afd000 fix(browser): accept legacy flattened act params (#31359)
* fix(browser-tool): accept flattened act params

* schema(browser-tool): add flattened act fields

* test(browser-tool): cover flattened act compatibility

* changelog: note browser act compatibility fix

* fix(schema): align browser act request fields
2026-03-01 23:21:07 -08:00
Vincent Koc
5b55c23948 fix(browser): evict stale extension relay targets from cache (#31362)
* fix(browser): prune stale extension relay targets

* test(browser): cover relay stale target pruning

* changelog: note extension relay stale target fix
2026-03-01 23:18:49 -08:00
Vincent Koc
db28dda120 fix(cli): let browser start honor --timeout (#31365)
* fix(cli): respect browser start timeout option

* test(cli): cover browser start timeout propagation

* changelog: note browser start timeout propagation fix
2026-03-01 23:16:23 -08:00
Vincent Koc
f4785c1a7b Docs: expand sandbox guide for common image and Docker bootstrap 2026-03-01 23:16:00 -08:00
Peter Steinberger
c00d5837d3 style(agents): format pdf tool test after rebase 2026-03-02 07:13:11 +00:00
Peter Steinberger
45d77cac16 test(agents): dedupe remaining tool and lock test scaffolds 2026-03-02 07:13:11 +00:00
Peter Steinberger
c3948800f4 refactor(agents): extract shared tool model helpers 2026-03-02 07:13:11 +00:00
Peter Steinberger
45888276a3 test(integration): dedupe messaging, secrets, and plugin test suites 2026-03-02 07:13:11 +00:00
Peter Steinberger
d3e0c0b29c test(gateway): dedupe gateway and infra test scaffolds 2026-03-02 07:13:10 +00:00
Peter Steinberger
cded1b960a test(commands): dedupe command and onboarding test cases 2026-03-02 07:13:10 +00:00
Peter Steinberger
7e29d604ba test(agents): dedupe agent and cron test scaffolds 2026-03-02 07:13:10 +00:00
Veast
281494ae52 fix(browser): include Chrome stderr and sandbox hint in CDP startup error (#29355)
* fix(browser): include Chrome stderr and sandbox hint in CDP startup error (#29312)

When Chrome fails to start and CDP times out, the error message previously
contained no diagnostic information, making it impossible to determine why
Chrome couldn't start (e.g. missing --no-sandbox in containers, GPU issues,
shared memory errors).

This change:
- Collects Chrome's stderr output and includes up to 2000 chars in the error
- On Linux, if noSandbox is not set, appends a hint to try browser.noSandbox: true

Closes #29312

* chore(browser): format chrome startup diagnostics

* fix(browser): detach stderr listener after Chrome starts to prevent memory leak

Named the anonymous listener so it can be removed via proc.stderr.off()
once CDP is confirmed reachable. Also clears the stderrChunks array on
success so the buffered data is eligible for GC.

Fixes the unbounded memory growth reported in code review: a long-lived
Chrome process emitting periodic warnings would keep appending to
stderrChunks indefinitely since the listener was never removed.

Addresses review comment from chatgpt-codex-connector on PR #29355.

* changelog: note cdp startup diagnostics improvement

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: 派尼尔 <painier@openclaw.ai>
2026-03-01 23:08:52 -08:00
jamtujest
cb491dfde5 feat(docker): add opt-in sandbox support for Docker deployments (#29974)
* feat(docker): add opt-in sandbox support for Docker deployments

Enable Docker-based sandbox isolation via OPENCLAW_SANDBOX=1 env var
in docker-setup.sh. This is a prerequisite for agents.defaults.sandbox
to function in any Docker deployment (self-hosted, Hostinger, DigitalOcean).

Changes:
- Dockerfile: add OPENCLAW_INSTALL_DOCKER_CLI build arg (~50MB, opt-in)
- docker-compose.yml: add commented-out docker.sock mount with docs
- docker-setup.sh: auto-detect Docker socket, inject mount, detect GID,
  build sandbox image, configure sandbox defaults, add group_add

All changes are opt-in. Zero impact on existing deployments.

Usage: OPENCLAW_SANDBOX=1 ./docker-setup.sh

Closes #29933
Related: #7575, #7827, #28401, #10361, #12505, #28326

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

* fix: address code review feedback on sandbox support

- Persist OPENCLAW_SANDBOX, DOCKER_GID, OPENCLAW_INSTALL_DOCKER_CLI
  to .env via upsert_env so group_add survives re-runs
- Show config set errors instead of swallowing them silently;
  report partial failure when sandbox config is incomplete
- Warn when Dockerfile.sandbox is missing but sandbox config
  is still applied (sandbox image won't exist)
- Fix non-canonical whitespace in apt sources.list entry
  by using printf instead of echo with line continuation

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

* fix: remove `local` outside function and guard sandbox behind Docker CLI check

- Remove `local` keyword from top-level `sandbox_config_ok` assignment
  which caused script exit under `set -euo pipefail` (bash `local`
  outside a function is an error)
- Add Docker CLI prerequisite check for pre-built (non-local) images:
  runs `docker --version` inside the container and skips sandbox setup
  with a clear warning if the CLI is missing
- Split sandbox block so config is only applied after prerequisites pass

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

* fix: defer docker.sock mount until sandbox prerequisites pass

Move Docker socket mounting from the early setup phase (before image
build/pull) to a dedicated compose overlay created only after:
1. Docker CLI is verified inside the container image
2. /var/run/docker.sock exists on the host

Previously the socket was mounted optimistically at startup, leaving
the host Docker daemon exposed even when sandbox setup was later
skipped due to missing Docker CLI. Now the gateway starts without
the socket, and a docker-compose.sandbox.yml overlay is generated
only when all prerequisites pass. The gateway restart at the end of
sandbox setup picks up both the socket mount and sandbox config.

Also moves group_add from write_extra_compose() into the sandbox
overlay, keeping all sandbox-specific compose configuration together.

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

* docs(docker): fix sandbox docs URL in setup output

* Docker: harden sandbox setup fallback behavior

* Tests: cover docker-setup sandbox edge paths

* Docker: roll back sandbox mode on partial config failure

* Tests: assert sandbox mode rollback on partial setup

* Docs: document Docker sandbox bootstrap env controls

* Changelog: credit Docker sandbox bootstrap hardening

* Update CHANGELOG.md

* Docker: verify Docker apt signing key fingerprint

* Docker: avoid sandbox overlay deps during policy writes

* Tests: assert no-deps sandbox rollback gateway recreate

* Docs: mention OPENCLAW_INSTALL_DOCKER_CLI in Docker env vars

---------

Co-authored-by: Jakub Karwowski <jakubkarwowski@Mac.lan>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 23:06:10 -08:00
Tyler Yust
f918b336d1 fix: agent-only announce path, BB message IDs, sender identity, SSRF allowlist (#23970)
* fix(agents): defer announces until descendant cleanup settles

* fix(bluebubbles): harden message metadata extraction

* feat(contributors): rank by composite score (commits, PRs, LOC, tenure)

* refactor(control-ui): move method guard after path checks to improve request handling

* fix subagent completion announce when only current run is pending

* fix(subagents): keep orchestrator runs active until descendants finish

* fix: prepare PR feedback follow-ups (#23970) (thanks @tyler6204)
2026-03-01 22:52:11 -08:00
Peter Steinberger
cfba64c9db test: fix pdf-tool fetch/model config mock typings 2026-03-02 06:48:01 +00:00
Peter Steinberger
e876c2c3b3 fix: finalize headless profile default landing (#14944) (thanks @BenediktSchackenberg) 2026-03-02 06:48:01 +00:00
Benedikt Schackenberg
d03928bb69 test: Add tests for headless/noSandbox profile preference
Cover all cases requested in review:
1. headless=true → defaultProfile = 'openclaw'
2. noSandbox=true → defaultProfile = 'openclaw'
3. both false → defaultProfile = 'chrome' (existing behavior)
4. explicit defaultProfile config overrides preference logic
5. custom profiles work in headless mode

Fixes: #14895
2026-03-02 06:48:01 +00:00
Benedikt Schackenberg
3e3b49cb94 fix(browser): prefer openclaw profile in headless/noSandbox environments
In headless or noSandbox server environments (like Ubuntu Server), the
Chrome extension relay cannot work because there is no GUI browser to
attach to. Previously, the default profile was 'chrome' (extension relay)
which caused snapshot/screenshot operations to fail with:

  'Chrome extension relay is running, but no tab is connected...'

This fix prefers the 'openclaw' profile (Playwright native mode) when
browser.headless=true or browser.noSandbox=true, while preserving the
'chrome' default for GUI environments where extension relay works.

Fixes: https://github.com/openclaw/openclaw/issues/14895

🤖 AI-assisted (Claude), fully tested: pnpm build && pnpm check && pnpm test
2026-03-02 06:48:01 +00:00
Tyler Yust
d0ac1b0195 feat: add PDF analysis tool with native provider support (#31319)
* feat: add PDF analysis tool with native provider support

New `pdf` tool for analyzing PDF documents with model-powered analysis.

Architecture:
- Native PDF path: sends raw PDF bytes directly to providers that support
  inline document input (Anthropic via DocumentBlockParam, Google Gemini
  via inlineData with application/pdf MIME type)
- Extraction fallback: for providers without native PDF support, extracts
  text via pdfjs-dist and rasterizes pages to images via @napi-rs/canvas,
  then sends through the standard vision/text completion path

Key features:
- Single PDF (`pdf` param) or multiple PDFs (`pdfs` array, up to 10)
- Page range selection (`pages` param, e.g. "1-5", "1,3,7-9")
- Model override (`model` param) and file size limits (`maxBytesMb`)
- Auto-detects provider capability and falls back gracefully
- Same security patterns as image tool (SSRF guards, sandbox support,
  local path roots, workspace-only policy)

Config (agents.defaults):
- pdfModel: primary/fallbacks (defaults to imageModel, then session model)
- pdfMaxBytesMb: max PDF file size (default: 10)
- pdfMaxPages: max pages to process (default: 20)

Model catalog:
- Extended ModelInputType to include "document" alongside "text"/"image"
- Added modelSupportsDocument() capability check

Files:
- src/agents/tools/pdf-tool.ts - main tool factory
- src/agents/tools/pdf-tool.helpers.ts - helpers (page range, config, etc.)
- src/agents/tools/pdf-native-providers.ts - direct API calls for Anthropic/Google
- src/agents/tools/pdf-tool.test.ts - 43 tests covering all paths
- Modified: model-catalog.ts, openclaw-tools.ts, config schema/types/labels/help

* fix: prepare pdf tool for merge (#31319) (thanks @tyler6204)
2026-03-01 22:39:12 -08:00
Peter Steinberger
31b6e58a1b docs: add relay reattach changelog attribution (#28725) (thanks @stone-jin) 2026-03-02 06:38:21 +00:00
stone-jin
04b3a51d3a fix(browser): preserve debugger attachment across relay disconnects during navigation reattach 2026-03-02 06:38:21 +00:00
Peter Steinberger
18cd77c8ce fix: cover relay reannounce minimal target path (#27630) (thanks @markmusson) 2026-03-02 06:33:28 +00:00
Mark Musson
591ff3c1c8 fix(browser-relay): fallback to cached targetId on target info lookup failure 2026-03-02 06:33:28 +00:00
Vincent Koc
3ae8e5ee91 Docs: add changelog entry for auth permission error (#31367)
* Docs: add changelog entry for auth permission error

* Update CHANGELOG.md
2026-03-01 22:30:47 -08:00
Peter Steinberger
b3cf6e7d77 fix: harden relay reconnect grace coverage (#30232) (thanks @Sid-Qin) 2026-03-02 06:28:50 +00:00
SidQin-cyber
f77f3fb839 fix(browser): tolerate brief extension relay disconnects on attached tabs
Keep extension relay tab metadata available across short extension worker drops and allow CDP clients to connect while waiting for reconnect. This prevents false "no tab connected" failures in environments where the extension worker disconnects transiently (e.g. WSLg/MV3).
2026-03-02 06:28:50 +00:00
Peter Steinberger
0eebae44f6 fix: test browser.request profile body fallback (#28852) (thanks @Sid-Qin) 2026-03-02 06:26:35 +00:00
SidQin-cyber
fa875a6bf7 fix(gateway): honor browser profile from request body for node proxy calls
Gateway browser.request only read profile from query.profile before invoking
browser.proxy on nodes. Calls that passed profile in POST body silently fell
back to the default profile, which could switch users into chrome extension
mode even when they explicitly requested openclaw profile.

Use query profile first, then fall back to body.profile when present.

Closes #28687
2026-03-02 06:26:35 +00:00
Sid
40e078a567 fix(auth): classify permission_error as auth_permanent for profile fallback (#31324)
When an OAuth auth profile returns HTTP 403 with permission_error
(e.g. expired plan), the error was not matched by the authPermanent
patterns. This caused the profile to receive only a short cooldown
instead of being disabled, so the gateway kept retrying the same
broken profile indefinitely.

Add "permission_error" and "not allowed for this organization" to
the authPermanent error patterns so these errors trigger the longer
billing/auth_permanent disable window and proper profile rotation.

Closes #31306

Made-with: Cursor

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 22:26:05 -08:00
Timothy Jordan
f2dbaf70fa docs: add Vercel sponsorship (#29270)
* docs: add Vercel sponsorship

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

* docs: fix README formatting

* docs: resize Vercel sponsor logo to match other logos

* docs: scale down Vercel SVG viewBox to match other sponsor logos

* Fixed ordering.

* md error fix

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:25:46 +00:00
SidQin-cyber
821b7c80a6 fix(browser): avoid extension profile startup deadlock in browser start
browser start for driver=extension required websocket tab attachment during
ensureBrowserAvailable, which can deadlock startup because tabs can only
attach after relay startup succeeds.

For extension profiles, only require relay HTTP reachability in startup and
leave tab attachment checks to ensureTabAvailable when a concrete tab action
is requested.

Closes #28701
2026-03-02 06:19:36 +00:00
Peter Steinberger
5b8f492a48 fix(security): harden spoofed system marker handling 2026-03-02 06:19:16 +00:00
SidQin-cyber
7c9d2c1d48 fix(browser): retry relay navigation after frame detach
Retry browser navigate once after transient frame-detached/target-closed errors by forcing a clean Playwright reconnect, so extension-relay sessions stay controllable across navigation swaps.

Closes #29431
2026-03-02 06:14:52 +00:00
zerone0x
376a52a5ba fix: use 0o644 for inbound media files to allow sandbox read access (#17943)
* fix: use 0o644 for inbound media files to allow sandbox read access

Inbound media files were saved with 0o600 permissions, making them
unreadable from Docker sandbox containers running as different users.

Change to 0o644 (world-readable) so sandboxed agents can access
downloaded attachments.

Fixes #17941

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

* test(media): assert URL-sourced inbound files use 0o644

* test(media): make redirect file-mode assertion platform-aware

* docs(media): clarify 0o644 is for sandbox UID compatibility

---------

Co-authored-by: zerone0x <zerone0x@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 22:14:39 -08:00
AaronWander
366374b4ff Sandbox: add actionable error when docker missing (#28547)
Co-authored-by: AaronWander <siralonne@163.com>
2026-03-01 22:14:26 -08:00
Peter Steinberger
3049ca840f docs: replace bare provider URLs with markdown links 2026-03-02 06:01:29 +00:00
Jannes Stubbemann
5bb26bf22a fix(browser): skip port ownership check for remote CDP profiles (#28780)
* fix(browser): skip port ownership check for remote CDP profiles

When a browser profile has a non-loopback cdpUrl (e.g. Browserless,
Kubernetes sidecar, or any external CDP service), the port-ownership
check incorrectly fires because we don't "own" the remote process.
This causes "Port is in use but not by openclaw" even though the
remote CDP service is working and reachable.

Guard the ownership error with !remoteCdp so remote profiles fall
through to the WebSocket retry/attach logic instead.

Fixes #15582

* fix: add TypeScript null guard for profileState.running

* chore(changelog): note remote CDP ownership fix credits

Refs #15582

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 22:00:16 -08:00
Sahil Satralkar
cda119b052 fix: handle missing systemctl in containers (#26089) (#26699)
* Daemon: handle missing systemctl in containers

* Daemon: harden missing-systemctl detection

* Daemon tests: cover systemctl spawn failure path

* Changelog: note container systemctl service-check fix

* Update CHANGELOG.md

* Daemon: fail closed on unknown systemctl is-enabled errors

* Daemon tests: cover is-enabled unknown-error path

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 21:48:06 -08:00
Peter Steinberger
5d78fcf1b5 docs: add missing message channels to readme 2026-03-02 05:46:57 +00:00
Peter Steinberger
bc0288bcfb docs: clarify adaptive thinking and openai websocket docs 2026-03-02 05:46:57 +00:00
Sid
e1e715c53d fix(gateway): skip device pairing for local backend self-connections (#30801)
* fix(gateway): skip device pairing for local backend self-connections

When gateway.tls is enabled, sessions_spawn (and other internal
callGateway operations) creates a new WebSocket to the gateway.
The gateway treated this self-connection like any external client
and enforced device pairing, rejecting it with "pairing required"
(close code 1008). This made sub-agent spawning impossible when
TLS was enabled in Docker with bind: "lan".

Skip pairing for connections that are gateway-client self-connections
from localhost with valid shared auth (token/password). These are
internal backend calls (e.g. sessions_spawn, subagent-announce) that
already have valid credentials and connect from the same host.

Closes #30740

* gateway: tighten backend self-pair bypass guard

* tests: cover backend self-pairing local-vs-remote auth path

* changelog: add gateway tls pairing fix credit

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 21:46:33 -08:00
Sid
3002f13ca7 feat(config): add openclaw config validate and improve startup error messages (#31220)
Merged via squash.

Prepared head SHA: 4598f2a541
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-02 00:45:51 -05:00
Vincent Koc
5a2200b280 fix(sessions): harden recycled PID lock recovery follow-up (#31320)
* fix: detect PID recycling in session write lock staleness check

The session lock uses isPidAlive() to determine if a lock holder is
still running. In containers, PID recycling can cause a different
process to inherit the same PID, making the lock appear valid when
the original holder is dead.

Record the process start time (field 22 of /proc/pid/stat) in the
lock file and compare it during staleness checks. If the PID is alive
but its start time differs from the recorded value, the lock is
treated as stale and reclaimed immediately.

Backward compatible: lock files without starttime are handled with
the existing PID-alive + age-based logic. Non-Linux platforms skip
the starttime check entirely (getProcessStartTime returns null).

* shared: harden pid starttime parsing

* sessions: validate lock pid/starttime payloads

* changelog: note recycled PID lock recovery fix

* changelog: credit hiroki and vincent on lock recovery fix

---------

Co-authored-by: HirokiKobayashi-R <hiroki@rhems-japan.co.jp>
2026-03-01 21:42:22 -08:00
Ayaan Zaidi
548a502c69 docs: sync android node docs with current pairing and capabilities 2026-03-02 11:08:51 +05:30
Nikolay Petrov
a9f1188785 sessions_spawn: inline attachments with redaction, lifecycle cleanup, and docs (#16761)
Add inline file attachment support for sessions_spawn (subagent runtime only):

- Schema: attachments[] (name, content, encoding, mimeType) and attachAs.mountPath hint
- Materialization: files written to .openclaw/attachments/<uuid>/ with manifest.json
- Validation: strict base64 decode, filename checks, size limits, duplicate detection
- Transcript redaction: sanitizeToolCallInputs redacts attachment content from persisted transcripts
- Lifecycle cleanup: safeRemoveAttachmentsDir with symlink-safe path containment check
- Config: tools.sessions_spawn.attachments (enabled, maxFiles, maxFileBytes, maxTotalBytes, retainOnSessionKeep)
- Registry: attachmentsDir/attachmentsRootDir/retainAttachmentsOnKeep on SubagentRunRecord
- ACP rejection: attachments rejected for runtime=acp with clear error message
- Docs: updated tools/index.md, concepts/session-tool.md, configuration-reference.md
- Tests: 85 new/updated tests across 5 test files

Fixes:
- Guard fs.rm in materialization catch block with try/catch (review concern #1)
- Remove unreachable fallback in safeRemoveAttachmentsDir (review concern #7)
- Move attachment cleanup out of retry path to avoid timing issues with announce loop

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
Co-authored-by: napetrov <napetrov@users.noreply.github.com>
2026-03-01 21:33:51 -08:00
Peter Steinberger
842deefe5d test: split fast lane from channel and gateway suites 2026-03-02 05:33:07 +00:00
Peter Steinberger
a13586619b test: move integration-heavy suites to e2e lane 2026-03-02 05:33:07 +00:00
Peter Steinberger
656121a12b test: micro-optimize hot unit test files 2026-03-02 05:33:07 +00:00
Peter Steinberger
1de3200973 refactor(infra): centralize boundary traversal and root path checks 2026-03-02 05:20:19 +00:00
Peter Steinberger
7fcec6ca3e refactor(streaming): share approval and stream message builders 2026-03-02 05:20:19 +00:00
Peter Steinberger
6b78544f82 refactor(commands): unify repeated ACP and routing flows 2026-03-02 05:20:19 +00:00
Peter Steinberger
2d31126e6a refactor(shared): extract reused path and normalization helpers 2026-03-02 05:20:19 +00:00
Peter Steinberger
264599cc1d refactor(core): share JSON utf8 byte counting helper 2026-03-02 05:20:19 +00:00
Peter Steinberger
4a1be98254 fix(diffs): harden viewer security and docs 2026-03-02 05:07:09 +00:00
Peter Steinberger
0ab2c82624 docs: dedupe 2026.3.1 changelog entries 2026-03-02 05:04:28 +00:00
Peter Steinberger
6ba7238ac6 build: bump versions to 2026.3.2 2026-03-02 04:55:53 +00:00
Umut CAN
d2472af724 Chore: add Dockerfile HEALTHCHECK and debug-log silent catch blocks (#11478)
* Docker: add /healthz-based container HEALTHCHECK

* Docs/Docker: document built-in image HEALTHCHECK

* Changelog: note Dockerfile healthcheck probe

* Docs/Docker: explain HEALTHCHECK behavior in plain language

* Docker: relax HEALTHCHECK interval to 3m

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 20:52:14 -08:00
Peter Steinberger
2a8ac974e1 build: prepare 2026.3.1 latest release 2026-03-02 04:50:11 +00:00
Alberto Leal
449511484d fix(gateway): allow ws:// to private network addresses (#28670)
* fix(gateway): allow ws:// to RFC 1918 private network addresses

resolve ws-private-network conflicts

* gateway: keep ws security strict-by-default with private opt-in

* gateway: apply private ws opt-in in connection detail guard

* gateway: apply private ws opt-in in websocket client

* onboarding: gate private ws urls behind explicit opt-in

* gateway tests: enforce strict ws defaults with private opt-in

* onboarding tests: validate private ws opt-in behavior

* gateway client tests: cover private ws env override

* gateway call tests: cover private ws env override

* changelog: add ws strict-default security entry for pr 28670

* docs(onboard): document private ws break-glass env

* docs(gateway): add private ws env to remote guide

* docs(docker): add private ws break-glass env var

* docs(security): add private ws break-glass guidance

* docs(config): document OPENCLAW_ALLOW_PRIVATE_WS

* Update CHANGELOG.md

* gateway: normalize private-ws host classification

* test(gateway): cover non-unicast ipv6 private-ws edges

* changelog: rename insecure private ws break-glass env

* docs(onboard): rename insecure private ws env

* docs(gateway): rename insecure private ws env in config reference

* docs(gateway): rename insecure private ws env in remote guide

* docs(security): rename insecure private ws env

* docs(docker): rename insecure private ws env

* test(onboard): rename insecure private ws env

* onboard: rename insecure private ws env

* test(gateway): rename insecure private ws env in call tests

* gateway: rename insecure private ws env in call flow

* test(gateway): rename insecure private ws env in client tests

* gateway: rename insecure private ws env in client

* docker: pass insecure private ws env to services

* docker-setup: persist insecure private ws env

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 20:49:45 -08:00
Peter Steinberger
d76b224e20 docs: update appcast for 2026.3.1 2026-03-02 04:41:39 +00:00
Peter Steinberger
92ad89da00 build: prepare 2026.3.1-beta.1 release 2026-03-02 04:38:03 +00:00
Vincent Koc
eeb72097ba Gateway: add healthz/readyz probe endpoints for container checks (#31272)
* Gateway: add HTTP liveness/readiness probe routes

* Gateway tests: cover probe route auth bypass and methods

* Docker Compose: add gateway /healthz healthcheck

* Docs: document Docker probe endpoints

* Dockerfile: note built-in probe endpoints

* Gateway: make probe routes fallback-only to avoid shadowing

* Gateway tests: verify probe paths do not shadow plugin routes

* Changelog: note gateway container probe endpoints
2026-03-01 20:36:58 -08:00
Peter Steinberger
0a1eac6b0b fix(ios): eliminate voice wake and xcode build warnings 2026-03-02 04:36:49 +00:00
Peter Steinberger
7073f63610 fix(ios): enforce main-actor device status APIs 2026-03-02 04:36:49 +00:00
Peter Steinberger
cb484f44e9 fix: resolve i18n merge conflict and test hoist failure 2026-03-02 04:36:11 +00:00
cyb1278588254
96ffbb5aaf CLI: add config path subcommand to print active config file path (#26256)
Merged via squash.

Prepared head SHA: b11c593a34
Co-authored-by: cyb1278588254 <48212932+cyb1278588254@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-01 23:33:20 -05:00
Peter Steinberger
dc2290aeb1 fix(ci): drop redundant env assertions in daemon status 2026-03-02 04:32:35 +00:00
Peter Steinberger
8b05e4091c fix(discord): prefer names in allowlist resolution logs 2026-03-02 04:31:10 +00:00
Vincent Koc
b7615e0ce3 Exec/ACP: inject OPENCLAW_SHELL into child shell env (#31271)
* exec: mark runtime shell context in exec env

* tests(exec): cover OPENCLAW_SHELL in gateway exec

* tests(exec): cover OPENCLAW_SHELL in pty mode

* acpx: mark runtime shell context for spawned process

* tests(acpx): log OPENCLAW_SHELL in runtime fixture

* tests(acpx): assert OPENCLAW_SHELL in runtime prompt

* docs(env): document OPENCLAW_SHELL runtime markers

* docs(exec): describe OPENCLAW_SHELL exec marker

* docs(acp): document OPENCLAW_SHELL acp marker

* docs(gateway): note OPENCLAW_SHELL for background exec

* tui: tag local shell runs with OPENCLAW_SHELL

* tests(tui): assert OPENCLAW_SHELL in local shell runner

* acp client: tag spawned bridge env with OPENCLAW_SHELL

* tests(acp): cover acp client OPENCLAW_SHELL env helper

* docs(env): include acp-client and tui-local shell markers

* docs(acp): document acp-client OPENCLAW_SHELL marker

* docs(tui): document tui-local OPENCLAW_SHELL marker

* exec: keep shell runtime env string-only for docker args

* changelog: note OPENCLAW_SHELL runtime markers
2026-03-01 20:31:06 -08:00
Peter Steinberger
aeb817353f style(changelog): apply oxfmt 2026-03-02 04:30:05 +00:00
Peter Steinberger
1c0d36eed0 fix(ci): resolve i18n typing and generated-policy drift 2026-03-02 04:29:18 +00:00
Peter Steinberger
fa9148400e fix(android): align lint gates and photo permission handling 2026-03-02 04:28:17 +00:00
Peter Steinberger
37d036714e fix(thinking): default Claude 4.6 to adaptive 2026-03-02 04:27:26 +00:00
Sid
4691aab019 fix(cron): guard against year-rollback in croner nextRun (#30777)
* fix(cron): guard against year-rollback in croner nextRun

Croner can return a past-year timestamp for some timezone/date
combinations (e.g. Asia/Shanghai).  When nextRun returns a value at or
before nowMs, retry from the next whole second and, if still stale,
from midnight-tomorrow UTC before giving up.

Closes #30351

* googlechat: guard API calls with SSRF-safe fetch

* test: fix hoisted plugin context mock setup

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 22:22:59 -06:00
Peter Steinberger
6fc0787bf0 chore(deps): bump workspace dependencies 2026-03-02 04:22:33 +00:00
Peter Steinberger
8208f5e822 docs: reorder unreleased changelog by user impact 2026-03-02 04:19:56 +00:00
Peter Steinberger
619dfa88cb fix(discord): enrich allowlist resolution logs 2026-03-02 04:19:37 +00:00
Peter Steinberger
d17f4432b3 chore: fix gate formatting and raw-fetch allowlist lines 2026-03-02 04:18:48 +00:00
Peter Steinberger
7b3f506e64 style(swift): apply swiftformat and swiftlint fixes 2026-03-02 04:15:43 +00:00
Peter Steinberger
e1f3ded033 refactor: split telegram delivery and unify media/frontmatter/i18n pipelines 2026-03-02 04:14:06 +00:00
Peter Steinberger
706cfcd54f fix: isolate docker onboard e2e config env 2026-03-02 04:10:28 +00:00
Peter Steinberger
f46bd2e0cc refactor(feishu): split monitor startup and transport concerns 2026-03-02 04:09:24 +00:00
Peter Steinberger
c0bf42f2a8 refactor: centralize delivery/path/media/version lifecycle 2026-03-02 04:04:36 +00:00
Peter Steinberger
f4f094fc3b test(mattermost): cover defaultAccount resolution 2026-03-02 04:03:55 +00:00
Peter Steinberger
41537e9303 fix(channels): add optional defaultAccount routing 2026-03-02 04:03:46 +00:00
Peter Steinberger
0437ac1a89 fix(gateway): raise health-monitor restart cap 2026-03-02 04:03:04 +00:00
Mark L
0f2dce0483 fix(agents): prioritize per-model thinking defaults (#30439)
* fix(agents): honor per-model thinking defaults

* fix(agents): preserve thinking fallback with model defaults

---------

Co-authored-by: Mark L <73659136+markliuyuxiang@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 04:00:02 +00:00
Peter Steinberger
3fc19ed7d7 fix: harden feishu startup probe sequencing (#29941) (thanks @bmendonca3) 2026-03-02 03:59:23 +00:00
bmendonca3
abc7b6fbec Feishu: skip duplicate bot-info retries after preflight 2026-03-02 03:59:23 +00:00
bmendonca3
bdca44693c Feishu: serialize startup bot-info probes 2026-03-02 03:59:23 +00:00
Peter Steinberger
02b1958760 fix(feishu): suppress stale replay typing indicators (#30709) (thanks @arkyu2077) 2026-03-02 03:53:24 +00:00
yuxh1996
7fbc40f821 fix(feishu): skip typing indicator on old messages after context compaction (#30418) 2026-03-02 03:53:24 +00:00
Mark L
5b06c8c6e3 fix(config): normalize gateway bind host aliases during migration (#30855)
* fix(config): normalize gateway bind host aliases during migration [AI-assisted]

* config(legacy): detect gateway.bind host aliases as legacy

* config(legacy): sanitize bind alias migration log output

* test(config): cover bind alias legacy detection and log escaping

* config(legacy): add source-literal gate to legacy rules

* config(legacy): make issue detection source-aware

* config(legacy): require source-literal gateway.bind alias detection

* config(io): pass parsed source to legacy issue detection

* test(config): cover resolved-only gateway.bind alias legacy detection

* changelog: format after #30855 rebase conflict resolution

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 19:53:00 -08:00
Sid
c9f0d6ac8e feat(agents): support thinkingDefault: "adaptive" for Anthropic models (#31227)
* feat(agents): support `thinkingDefault: "adaptive"` for Anthropic models

Anthropic's Opus 4.6 and Sonnet 4.6 support adaptive thinking where the
model dynamically decides when and how much to think.  This is now
Anthropic's recommended mode and `budget_tokens` is deprecated on these
models.

Add "adaptive" as a valid thinking level:
- Config: `agents.defaults.thinkingDefault: "adaptive"`
- CLI: `/think adaptive` or `/think auto`
- Pi SDK mapping: "adaptive" → "medium" effort at the pi-agent-core
  layer, which the Anthropic provider translates to
  `thinking.type: "adaptive"` with `output_config.effort: "medium"`
- Provider fallbacks: OpenRouter and Google map "adaptive" to their
  respective "medium" equivalents

Closes #30880

Made-with: Cursor

* style(changelog): format changelog with oxfmt

* test(types): fix strict typing in runtime/plugin-context tests

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 03:52:02 +00:00
Peter Steinberger
ede944371f fix(telegram): land #31067 first-chunk voice-fallback reply refs (@xdanger)
Landed from contributor PR #31067 by @xdanger.

Co-authored-by: Kros Dai <xdanger@gmail.com>
2026-03-02 03:50:09 +00:00
Anandesh Sharma
61ef76edb5 docs(gateway): document Docker bridge networking and loopback bind caveat (#28001)
* docs(gateway): document Docker bridge networking and loopback bind caveat

The default loopback bind makes the gateway unreachable with Docker
bridge networking because port-forwarded traffic arrives on eth0, not
lo. Add a note in both the Dockerfile and the configuration reference
explaining the workarounds (--network host or bind: lan).

Fixes #27950

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

* docs(docker): note legacy gateway.bind alias migration

* docs(gateway): clarify legacy bind alias auto-migration

* docs(docker): require bind mode values in gateway.bind

* docs(gateway): avoid bind alias auto-migration claim

* changelog: add #28001 docker bind docs credit

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 19:45:27 -08:00
Charles Dusek
92199ac129 fix(agents): unblock gpt-5.3-codex API-key routing and replay (#31083)
* fix(agents): unblock gpt-5.3-codex API-key replay path

* fix(agents): scope OpenAI replay ID rewrites per turn

* test: fix nodes-tool mock typing and reformat telegram accounts
2026-03-02 03:45:12 +00:00
Peter Steinberger
e1bf9591c3 fix(web-tools): land #31176 allow RFC2544 trusted fetch range (@sunkinux)
Landed from contributor PR #31176 by @sunkinux.

Co-authored-by: sunkinux <sunkinux@users.noreply.github.com>
2026-03-02 03:43:25 +00:00
Peter Steinberger
2a252a14cc fix(feishu): harden target routing, dedupe, and reply fallback 2026-03-02 03:41:53 +00:00
Clawborn
77ccd35e5e Fix onboard ignoring OPENCLAW_GATEWAY_TOKEN env var (#22658)
* Fix onboard ignoring OPENCLAW_GATEWAY_TOKEN env var

When running onboard via docker-setup.sh, the QuickStart wizard
generates its own 48-char token instead of using the 64-char token
already set in OPENCLAW_GATEWAY_TOKEN. This causes a token mismatch
that breaks all CLI commands after setup.

Check process.env.OPENCLAW_GATEWAY_TOKEN before falling back to
randomToken() in both the interactive QuickStart path and the
non-interactive path.

Closes #22638

Co-authored-by: Clawborn <tianrun.yang103@gmail.com>

* Tests: cover quickstart env token fallback

* Changelog: note docker onboarding token parity fix

* Tests: restore env var after non-interactive token fallback test

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 19:40:40 -08:00
Gustavo Madeira Santana
8e69fd80e0 Gateway: harden control-ui vs plugin HTTP precedence 2026-03-01 22:38:14 -05:00
Gustavo Madeira Santana
6532757cdf Diffs: add viewer payload validation and presentation defaults 2026-03-01 22:38:14 -05:00
Peter Steinberger
0202d79df4 fix(inbound-meta): land #30984 include account_id context (@Stxle2)
Landed from contributor PR #30984 by @Stxle2.

Co-authored-by: Stxle2 <166609401+Stxle2@users.noreply.github.com>
2026-03-02 03:36:48 +00:00
Ayaan Zaidi
c13b35b83d feat(telegram): improve DM topics support (#30579) (thanks @kesor) 2026-03-02 09:06:45 +05:30
Peter Steinberger
aafc4d56e3 docs(changelog): credit fixes from PRs #31058 #31211 #30941 #31047 #31205 2026-03-02 03:35:49 +00:00
Peter Steinberger
00dcd931cb test(fs-safe): assert directory-read errors never leak EISDIR text 2026-03-02 03:35:20 +00:00
倪汉杰0668001185
6398a0ba8f fix(infra): avoid EISDIR leak to messaging when Read targets directory (Closes #31186) 2026-03-02 03:35:20 +00:00
Dale Babiy
8a4d8c889c fix(secrets): normalize inline SecretRef token/key to tokenRef/keyRef in runtime snapshot (#31047)
* fix(secrets): normalize inline SecretRef token/key to tokenRef/keyRef in runtime snapshot

When auth-profiles.json uses an inline SecretRef as the token or key
value directly (e.g. `"token": {"source":"file",...}`), the resolved
plaintext was written back to disk on every updateAuthProfileStoreWithLock
call, overwriting the SecretRef.

Root cause: collectTokenProfileAssignment and collectApiKeyProfileAssignment
detected inline SecretRefs but did not promote them to the canonical
tokenRef/keyRef fields. saveAuthProfileStore only strips plaintext when
tokenRef/keyRef is set, so the inline case fell through and persisted
plaintext on every save.

Fix: when an inline SecretRef is detected and no explicit tokenRef/keyRef
exists, promote it to the canonical field and delete the inline form.
saveAuthProfileStore then correctly strips the resolved plaintext on write.

Fixes #29108

* fix test: cast inline SecretRef loadAuthStore mocks to AuthProfileStore

* fix(secrets): fix TypeScript type error in runtime test loadAuthStore lambda

* test(secrets): keep explicit keyRef precedence over inline key ref

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 03:34:23 +00:00
Peter Steinberger
d446722f2f docs(changelog): merge post-v2026.2.26 release notes 2026-03-02 03:34:00 +00:00
Peter Steinberger
edd9319552 fix(feishu): land #31209 prevent system preview leakage (@stakeswky)
Landed from contributor PR #31209 by @stakeswky.

Co-authored-by: stakeswky <stakeswky@users.noreply.github.com>
2026-03-02 03:33:48 +00:00
Peter Steinberger
072e1e9e38 test(session): cover internal route without external fallback 2026-03-02 03:33:12 +00:00
graysurf
95db5bb5e8 fix(session): preserve external lastTo routing for internal turns 2026-03-02 03:33:12 +00:00
Peter Steinberger
0fa5d6ed2e test(usage): cover negative prompt_tokens alias clamp 2026-03-02 03:31:47 +00:00
scoootscooob
20467d987d fix(usage): clamp negative input token counts to zero
Some OpenAI-format providers (via pi-ai) pre-subtract cached_tokens from
prompt_tokens upstream.  When cached_tokens exceeds prompt_tokens due to
provider inconsistencies the subtraction produces a negative input value
that flows through to the TUI status bar and /usage dashboard.

Clamp rawInput to 0 in normalizeUsage() so downstream consumers never
see nonsensical negative token counts.

Closes #30765

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:31:47 +00:00
Glucksberg
08c35eb13f fix(cron): re-arm one-shot at-jobs when rescheduled after completion (openclaw#28915) thanks @Glucksberg
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 21:31:24 -06:00
lbo728
904016b7de fix(origin-check): honour "*" wildcard in gateway.controlUi.allowedOrigins
When gateway.controlUi.allowedOrigins is set to ["*"], the Control UI
WebSocket was still rejected with "origin not allowed" for any non-
loopback origin (e.g. Tailscale IPs, LAN addresses).

Root cause: checkBrowserOrigin() compared each allowedOrigins entry
against the parsed request origin via a literal Array#includes(). The
entry "*" never equals an actual origin string, so the wildcard was
silently ignored and all remote connections were blocked.

Fix: check for the literal "*" entry before the per-origin comparison
and return ok:true immediately when found.

Closes #30990
2026-03-02 03:30:20 +00:00
Peter Steinberger
08f8aea32e fix(signal): land #31138 syncMessage presence filtering (@Sid-Qin)
Landed from contributor PR #31138 by @Sid-Qin.

Co-authored-by: Sid-Qin <sidqin0410@gmail.com>
2026-03-02 03:28:25 +00:00
Peter Steinberger
22666034a0 docs(changelog): credit feishu fix contributors 2026-03-02 03:24:21 +00:00
Vincent Koc
1a42ea3abf fix(auto-reply): normalize block-reply callback to Promise for timeout path (#31200)
* Auto-reply: wrap block reply callback in Promise.resolve for timeout safety

* Build: add strict smoke build script for CI regression gating

* CI: gate strict TS smoke build in check workflow

* docs(changelog): add auto-reply block reply timeout fix under Unreleased

* docs(changelog): credit original #19779 contributor and vincentkoc
2026-03-01 19:23:38 -08:00
Vincent Koc
030565b18c Docker: add OCI base-image labels and document base-image metadata (#31196)
* Docker: add OCI base image labels

* Docs(Docker): document base image metadata context

* Changelog: note Docker base annotation docs update

* Changelog: add author credit for Docker base annotations

* Update docker.md

* Docker: add OCI source and docs labels

* CI(Docker): publish OCI revision/version labels

* Docs(Docker): list OCI image annotations

* Changelog: expand OCI annotation coverage note

* Docker: set OCI license annotation to MIT

* Docs(Docker): align OCI license annotation to MIT

* Docker: note docs sync path for OCI annotations

* Docker: normalize OCI label block indentation
2026-03-01 19:22:44 -08:00
Peter Steinberger
6ea3a47dae fix(feishu): harden routing, parsing, and media delivery 2026-03-02 03:22:07 +00:00
Peter Steinberger
cdbed3c9b1 fix(googlechat): land #30965 thread reply option support (@novan)
Landed from contributor PR #30965 by @novan.

Co-authored-by: novan <novan@users.noreply.github.com>
2026-03-02 03:16:48 +00:00
Peter Steinberger
355b4c62bc fix(mattermost): land #30891 route private channels as group (@BlueBirdBack)
Landed from contributor PR #30891 by @BlueBirdBack.

Co-authored-by: BlueBirdBack <BlueBirdBack@users.noreply.github.com>
2026-03-02 03:14:17 +00:00
Peter Steinberger
6bea38b21f fix(models): land #31202 normalize custom provider keys (@stakeswky)
Landed from contributor PR #31202 by @stakeswky.

Co-authored-by: stakeswky <stakeswky@users.noreply.github.com>
2026-03-02 03:11:55 +00:00
Peter Steinberger
342bf4838e fix(cli): preserve json stdout while keeping doctor migration (#24368) (thanks @altaywtf) 2026-03-02 03:10:02 +00:00
Altay
67b98139b9 test(cli): avoid brittle mock call indexing in json-mode checks 2026-03-02 03:10:02 +00:00
Altay
9e4a366ee6 fix(cli): keep json preflight stdout machine-readable 2026-03-02 03:10:02 +00:00
Peter Steinberger
493ebb915b refactor: simplify telegram delivery and outbound session resolver flow 2026-03-02 03:09:40 +00:00
Peter Steinberger
166ae8f002 fix(matrix): land #31201 preserve room ID casing (@williamos-dev)
Landed from contributor PR #31201 by @williamos-dev.

Co-authored-by: williamos-dev <williamos-dev@users.noreply.github.com>
2026-03-02 03:09:23 +00:00
Peter Steinberger
efd303dbc4 fix: normalize Discord wildcard sentinel parsing (#29459) (thanks @Sid-Qin) 2026-03-02 03:08:32 +00:00
SidQin-cyber
6210d2e238 fix(discord): prevent wildcard component registration collisions
Assign distinct sentinel registration ids to Discord wildcard handlers while preserving wildcard parser keys, so select/menu/modal handlers no longer get dropped on runtimes that dedupe by raw customId.
2026-03-02 03:08:32 +00:00
Peter Steinberger
c869ca4bbf fix: harden discord agent cid parsing (#29013) (thanks @Jacky1n7) 2026-03-02 03:07:48 +00:00
李肖然
c14c17403e style: oxfmt for agent-components 2026-03-02 03:07:48 +00:00
李肖然
e95f96f77a fix(discord): guard cid decode to avoid URIError 2026-03-02 03:07:48 +00:00
李肖然
9aba8422ca fix(discord): accept cid in agent component interactions 2026-03-02 03:07:48 +00:00
Peter Steinberger
25b731c34a fix: harden discord media fallback regressions (#28906) (thanks @Sid-Qin) 2026-03-02 03:05:12 +00:00
SidQin-cyber
0a67033fe3 fix(discord): keep attachment metadata when media fetch is blocked
Preserve inbound attachment/sticker metadata in Discord message context when media download fails (for example due to SSRF blocking), so agents still see file references instead of silent drops.

Closes #28816
2026-03-02 03:05:12 +00:00
Peter Steinberger
e4e5d9c98c fix(model): land #30932 auth-profile @ parsing for /model (@haosenwang1018)
Landed from contributor PR #30932 by @haosenwang1018.

Co-authored-by: haosenwang1018 <haosenwang1018@users.noreply.github.com>
2026-03-02 03:05:03 +00:00
Peter Steinberger
15c1c93a95 docs: add missing changelog entry for #31064 2026-03-02 03:04:10 +00:00
Hyup
9c03f8be08 telegram: retry media fetch with IPv4 fallback on connect errors (#30554)
* telegram: retry fetch once with IPv4 fallback on connect errors

* test(telegram): format fetch fallback test

* style(telegram): apply oxfmt for fetch test

* fix(telegram): retry ipv4 fallback per request

* test: harden telegram ipv4 fallback coverage (#30554)

---------

Co-authored-by: root <root@vultr.guest>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 03:00:33 +00:00
Peter Steinberger
31c4722e90 docs: credit telegram empty-final regression coverage (#30746) 2026-03-02 02:59:08 +00:00
Rylen Anil
48d369749c fix(telegram): skip null/undefined final text payloads 2026-03-02 02:59:08 +00:00
liuxiaopai-ai
e6e3a7b497 fix(telegram): retry DM thread sends without message_thread_id [AI-assisted] 2026-03-02 02:58:15 +00:00
Peter Steinberger
ef9085927b test: cover voice fallback first-chunk reply behavior (#31077) 2026-03-02 02:57:10 +00:00
scoootscooob
2a381e6d7b fix(telegram): replyToMode 'first' now only applies reply-to to first chunk
The `replyToMessageIdForPayload` was computed once outside the chunk
and media loops, so all chunks received the same reply-to ID even when
replyToMode was set to "first". This replaces the static binding with
a lazy `resolveReplyTo()` function that checks `hasReplied` at each
send site, and updates `hasReplied` immediately after the first
successful send.

Fixes #31039

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:57:10 +00:00
Brian Le
f64d25bd3e fix(telegram): scope DM topic thread keys by chat id (#31064)
* fix(telegram): scope DM topic thread keys by chat id

* test(telegram): update dm topic session-key expectation

* fix(telegram): parse scoped dm thread ids in outbound recovery

* chore(telegram): format accounts config merge block

* test(nodes): simplify mocked exports for ts tuple spreads
2026-03-02 02:54:45 +00:00
Tak Hoffman
bbab94c1fe security(feishu): bind doc create grants to trusted requester context (#31184)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 20:51:45 -06:00
不做了睡大觉
e482da6682 fix(ollama): prioritize provider baseUrl for embedded runner (#30964)
* fix(ollama): honor provider baseUrl in embedded runner

* Embedded Ollama: clarify provider baseUrl precedence comment

* Changelog: note embedded Ollama baseUrl precedence fix

* Telegram: apply required formatter update in accounts config merge

* Revert "Telegram: apply required formatter update in accounts config merge"

This reverts commit d372b26975.

* Update CHANGELOG.md

---------

Co-authored-by: User <user@example.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 18:38:42 -08:00
Gustavo Madeira Santana
fd341d0d3f docs(changelog): add diffs entry 2026-03-01 21:36:42 -05:00
Peter Steinberger
b0c7f1ebe2 fix: harden sessions_spawn delivery params and telegram account routing (#31000, #31110) 2026-03-02 02:35:48 +00:00
Peter Steinberger
684ac44b71 fix(ui): land #31133 cron edit form viewport scrolling (@Sid-Qin)
Landed from contributor PR #31133 by @Sid-Qin.

Co-authored-by: Sid-Qin <Sid-Qin@users.noreply.github.com>
2026-03-02 02:34:43 +00:00
Peter Steinberger
8eac33d4e0 fix(ui): land #30978 debug event log full-width payloads (@stozo04)
Landed from contributor PR #30978 by @stozo04.

Co-authored-by: stozo04 <stozo04@users.noreply.github.com>
2026-03-02 02:32:18 +00:00
Vincent Koc
601d1ccd24 Docs(Docker): clarify official GHCR image usage and setup flow (#31180)
* Add pre built images to docker docs

* Docs(Docker): clarify official GHCR image guidance

* Changelog: document Docker docs image clarification

* Update CHANGELOG.md

---------

Co-authored-by: Ken <ken@ipl31.net>
2026-03-01 18:31:20 -08:00
Peter Steinberger
5850045df6 fix(cron): land #31145 explicit delivery none in editor (@byungsker)
Landed from contributor PR #31145 by @byungsker.

Co-authored-by: byungsker <byungsker@users.noreply.github.com>
2026-03-02 02:29:42 +00:00
Peter Steinberger
1da7906a5d fix(line): land #31151 M4A voice MIME detection (@scoootscooob)
Landed from contributor PR #31151 by @scoootscooob.

Co-authored-by: scoootscooob <scoootscooob@users.noreply.github.com>
2026-03-02 02:26:41 +00:00
Peter Steinberger
a1a8ec6870 fix(windows): land #31147 plugin install spawn EINVAL (@codertony)
Landed from contributor PR #31147 by @codertony.

Co-authored-by: codertony <codertony@users.noreply.github.com>
2026-03-02 02:23:53 +00:00
Peter Steinberger
00d2df46c7 docs(changelog): note security audit and slack download scope hardening 2026-03-02 02:23:43 +00:00
Peter Steinberger
40fda40aa7 fix(slack): scope download-file to channel and thread context 2026-03-02 02:23:22 +00:00
Peter Steinberger
17bae93680 fix(security): warn on wildcard control-ui origins and feishu owner grants 2026-03-02 02:23:22 +00:00
Peter Steinberger
cc0806dfab docs(discord): standardize eventQueue timeout guidance 2026-03-02 02:22:59 +00:00
Peter Steinberger
4f8c49e85b docs: backfill telegram changelog credits for merged PRs 2026-03-02 02:14:14 +00:00
Jose E Velez
0c8fa63b93 feat: lightweight bootstrap context mode for heartbeat/cron runs (openclaw#26064) thanks @jose-velez
Verified:
- pnpm build
- pnpm check (fails on pre-existing unrelated repo issues in extensions/diffs and src/agents/tools/nodes-tool.test.ts)
- pnpm vitest run src/agents/bootstrap-files.test.ts src/infra/heartbeat-runner.model-override.test.ts src/cli/cron-cli.test.ts
- pnpm test:macmini (fails on pre-existing extensions/diffs import errors; touched suites pass)

Co-authored-by: jose-velez <10926182+jose-velez@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 20:13:24 -06:00
Peter Steinberger
0a182bb4d1 docs(changelog): add entries for recent landed Discord PRs 2026-03-02 02:11:21 +00:00
liuxiaopai-ai
042d06a19b Telegram: stop bot on polling teardown 2026-03-02 02:09:52 +00:00
Phineas1500
666a4763ee Telegram: preserve proxy-aware global dispatcher 2026-03-02 02:09:49 +00:00
Peter Steinberger
b3990ad58a fix: add changelog for #8805 (thanks @Arthur742Ramos) 2026-03-02 02:09:40 +00:00
Arthur Freitas Ramos
2dcd2f9094 fix: refresh Copilot token before expiry and retry on auth errors
GitHub Copilot API tokens expire after ~30 minutes. When OpenClaw spawns
a long-running subagent using Copilot as the provider, the token would
expire mid-session with no recovery mechanism, causing 401 auth errors.

This commit adds:
- Periodic token refresh scheduled 5 minutes before expiry
- Auth error detection with automatic token refresh and single retry
- Proper timer cleanup on session shutdown to prevent leaks

The implementation uses a per-attempt retry flag to ensure each auth
error can trigger one refresh+retry cycle without creating infinite
retry loops.

🤖 AI-assisted: This fix was developed with GitHub Copilot CLI assistance.
Testing: Fully tested with 3 new unit tests covering auth retry, retry
reset, and timer cleanup scenarios. All 11 auth rotation tests pass.
2026-03-02 02:09:40 +00:00
Peter Steinberger
e54ddf6161 fix: add changelog for #19077 (thanks @ayanesakura) 2026-03-02 02:08:27 +00:00
Ayane
5b562e96cb test: add missing ENETRESET test case 2026-03-02 02:08:27 +00:00
Ayane
76ed274aad fix(agents): trigger model failover on connection-refused and network-unreachable errors
Previously, only ETIMEDOUT / ESOCKETTIMEDOUT / ECONNRESET / ECONNABORTED
were recognised as failover-worthy network errors. Connection-level
failures such as ECONNREFUSED (server down), ENETUNREACH / EHOSTUNREACH
(network disconnected), ENETRESET, and EAI_AGAIN (DNS failure) were
treated as unknown errors and did not advance the fallback chain.

This is particularly impactful when a local fallback model (e.g. Ollama)
is configured: if the remote provider is unreachable due to a network
outage, the gateway should fall back to the local model instead of
returning an error to the user.

Add the missing error codes to resolveFailoverReasonFromError() and
corresponding e2e tests.

Closes #18868
2026-03-02 02:08:27 +00:00
YUJIE2002
3b2ed8fe6f fix(telegram): prevent channel-level groups from leaking to all accounts in multi-account setups
In multi-account Telegram configurations, `mergeTelegramAccountConfig()`
performs a shallow merge of channel-level config onto each account. This
causes channel-level `groups` to be inherited by ALL accounts, including
those whose bots are not members of the configured groups.

When a secondary bot attempts to handle group messages for a group it is
not in, the failure disrupts message delivery for all accounts — causing
silent message loss with no errors in logs.

Fix: exclude `groups` from the base spread (like `accounts` already is)
and only apply channel-level groups as fallback in single-account setups
for backward compatibility. Multi-account setups must use account-level
groups config.

Added 5 test cases covering single-account inheritance, multi-account
isolation, account-level priority, and backward compatibility.

Fixes #30673
2026-03-02 02:08:11 +00:00
openperf
8247c25a32 fix(telegram): check chat allowlist before sender allowlist in group policy
When groupPolicy is "allowlist", the sender allowlist empty-entries
guard ran before the chat-level allowlist check. This caused groups
that were explicitly configured in the groups config to be silently
rejected when no allowFrom / groupAllowFrom entries existed.

Move the checkChatAllowlist block before the sender allowlist guard
and introduce a chatExplicitlyAllowed flag that distinguishes a
dedicated group entry (groupConfig is set) from a wildcard-only
match. When the chat is explicitly allowed and no sender entries
exist, skip the sender check entirely — the group ID itself acts
as the authorization.

Fixes #30613.
2026-03-02 02:08:09 +00:00
SidQin-cyber
60f8e832e0 fix(telegram): handle sendVoice caption-too-long by resending without caption
When TTS text exceeds Telegram's 1024-char caption limit, sendVoice
throws "message caption is too long" and the entire reply (voice +
text) is lost. Now catch this specific error, resend the voice note
without caption, then deliver the full text as a separate message.

Closes #30980

Made-with: Cursor
2026-03-02 02:07:57 +00:00
Glucksberg
a262a3ea08 fix(docker): ensure agent directory permissions in docker-setup.sh (#28841)
* fix(docker): ensure agent directory permissions in docker-setup.sh

* fix(docker): restrict chown to config-dir mount, not workspace

The previous 'chown -R node:node /home/node/.openclaw' call crossed into
the workspace bind mount on Linux hosts, recursively rewriting ownership
of all user project files in the workspace directory.

Fix: use 'find -xdev' to restrict chown to the config-dir filesystem
only (won't cross bind-mount boundaries). Then separately chown only
the OpenClaw metadata subdirectory (.openclaw/) within the workspace,
leaving the user's project files untouched.

Addresses review comment on PR #28841.
2026-03-01 18:07:34 -08:00
Glucksberg
a25a73e707 discord: expose EventQueue listenerTimeout as configurable option (fixes #24458) 2026-03-02 02:06:24 +00:00
dhananjai1729
8629b996a1 fix(discord): restrict token fallback to transport/timeout errors only
Address review feedback: only fall back to token-based ID extraction
on transport/timeout errors (catch block), not on HTTP auth failures
(401/403) which should fail fast to surface credential issues early.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:05:48 +00:00
dhananjai1729
3efd190aca test(discord): add unit tests for parseApplicationIdFromToken
Cover valid tokens, large snowflake IDs exceeding MAX_SAFE_INTEGER,
Bot-prefixed tokens, and various invalid/edge-case inputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:05:48 +00:00
dhananjai1729
4b2e35ab95 fix(discord): add token-based fallback for application ID resolution
When the Discord API call to /oauth2/applications/@me fails (timeout,
network error), the bot fails to start with "Failed to resolve Discord
application id". Add a fallback that extracts the application ID by
base64-decoding the first segment of the bot token, keeping it as a
string to avoid precision loss for snowflake IDs exceeding
Number.MAX_SAFE_INTEGER (2^53 - 1).

Fixes #29608

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:05:48 +00:00
Pushkar Kathayat
7f4d1b7531 fix(discord): support applied_tags parameter for forum thread creation
Forum channels that require tags fail with "A tag is required" when
creating threads because there was no way to pass tag IDs. Add
appliedTags parameter to the thread-create action so forum posts can
include required tags from the channel's available_tags list.
2026-03-02 02:05:11 +00:00
Ash (Bug Lab)
5b64b96c6c fix(discord): add ackReactionScope channel override + off/none values (#28268) 2026-03-02 02:04:39 +00:00
haosenwang1018
60330e011b fix(discord): log ignored messages from non-allowlisted channels
Closes #30676

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:03:44 +00:00
zerone0x
a5f0a9240f fix(cron): retry rename on EBUSY and fall back to copyFile on Windows
Landed from contributor PR #16932 with additional changelog alignment and verification.
2026-03-01 20:02:24 -06:00
FlamesCN
aaa7de45fa fix(cron): prevent armTimer tight loop when job has stuck runningAtMs (openclaw#29853) thanks @FlamesCN
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: FlamesCN <12966659+FlamesCN@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:58:58 -06:00
Peter Steinberger
ffe1937b92 fix(cli): set cron run exit code from run outcome (land #31121 by @Sid-Qin)
Landed-from: #31121
Contributor: @Sid-Qin
Co-authored-by: Sid <sidqin0410@gmail.com>
2026-03-02 01:58:39 +00:00
Mark L
9670ccfc41 Control UI/Cron: persist delivery mode none on edit (openclaw#31114) thanks @liuxiaopai-ai
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:58:13 -06:00
C. Liao
313a655d13 fix(cron): reject sessionTarget "main" for non-default agents at creation time (openclaw#30217) thanks @liaosvcaf
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: liaosvcaf <51533973+liaosvcaf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:54:53 -06:00
Peter Steinberger
e70fc5eb62 fix(nodes): cap screen_record duration to 5 minutes (land #31106 by @BlueBirdBack)
Landed-from: #31106
Contributor: @BlueBirdBack
Co-authored-by: BlueBirdBack <126304167+BlueBirdBack@users.noreply.github.com>
2026-03-02 01:53:20 +00:00
charo
757e09fe43 fix(cron): recover flat patch params for update action and fix schema (openclaw#23221) thanks @charojo
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: charojo <4084797+charojo@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:50:51 -06:00
Peter Steinberger
a779c2ca6a fix(telegram): skip nullish final text sends (land #30969 by @haosenwang1018)
Landed-from: #30969
Contributor: @haosenwang1018
Co-authored-by: Sense_wang <167664334+haosenwang1018@users.noreply.github.com>
2026-03-02 01:50:25 +00:00
Peter Steinberger
dba039f016 fix(doctor): use posix path semantics for linux sd detection 2026-03-02 01:48:14 +00:00
Peter Steinberger
70ee256ae0 fix(routing): treat group/channel peer.kind as equivalent (land #31135 by @Sid-Qin)
Landed-from: #31135
Contributor: @Sid-Qin
Co-authored-by: Sid <sidqin0410@gmail.com>
2026-03-02 01:47:02 +00:00
Mark L
e076665e5e test(cron): add Asia/Shanghai year-regression coverage [AI-assisted] (openclaw#30565) thanks @liuxiaopai-ai
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:46:06 -06:00
Peter Steinberger
65e13c7b6e fix(fs): honor unset tools.fs.workspaceOnly default (land #31128 by @SaucePackets)
Landed-from: #31128
Contributor: @SaucePackets
Co-authored-by: SaucePackets <33006469+SaucePackets@users.noreply.github.com>
2026-03-02 01:43:50 +00:00
Mark L
f1354869bd Node install: persist gateway token in service env (#31122)
* Node daemon: persist gateway token env

* changelog: add credits for node gateway token fix

* changelog: credit byungsker for node token service fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 17:35:24 -08:00
StingNing
ca770622b3 Cron: fix 1/3 timeout on fresh isolated CLI runs (openclaw#30140) thanks @ningding97
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: ningding97 <17723822+ningding97@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 19:34:18 -06:00
Peter Steinberger
949200d7cb test(browser): fix windows download tmp path assertions 2026-03-02 01:32:28 +00:00
Peter Steinberger
68a8a98ab7 fix(acpx): default strict windows wrapper policy on windows 2026-03-02 01:31:32 +00:00
Peter Steinberger
f8459ef46c docs(security): document sessions_spawn sandbox=require hardening 2026-03-02 01:29:19 +00:00
Peter Steinberger
f53ea0b74b docs(changelog): add entries for PRs #31090 #31105 #31093 #31088 2026-03-02 01:28:58 +00:00
Beer van der Drift
feefedfb83 fix: allow docker cli container to connect to gateway (#12504)
* Docker: route CLI through gateway network namespace

* Tests: assert Docker Compose CLI namespace wiring

* Changelog: add Docker Compose CLI connectivity fix

* Docker: pin docker setup gateway mode and bind

* Tests: cover docker setup mode and bind sync

* Docs: clarify Docker LAN vs loopback gateway targeting

* Changelog: expand Docker #12504 targeting note

* Docker: default optional CLAUDE compose vars to empty

* Docs(Docker): document non-interactive compose runs

* Changelog: note docker compose env-noise reduction

* Docker: restore onboarding Tailscale guidance

* Docker: simplify onboarding output and clarify Tailscale

* Docker: harden shared-namespace CLI container

* Docs(Docker): document shared-namespace trust boundary

* Changelog: note docker shared-namespace hardening

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 17:28:35 -08:00
Peter Steinberger
710004e011 fix(security): harden root-scoped writes against symlink races 2026-03-02 01:27:46 +00:00
Peter Steinberger
bfeadb80b6 feat(agents): add sessions_spawn sandbox require mode 2026-03-02 01:27:34 +00:00
Peter Steinberger
a6a742f3d0 fix(auto-reply): land #31080 from @scoootscooob
Co-authored-by: scoootscooob <zhentongfan@gmail.com>
2026-03-02 01:17:42 +00:00
Peter Steinberger
e7cd4bf1bd refactor(web): split trusted and strict web tool fetch paths 2026-03-02 01:14:06 +00:00
Vincent Koc
e07c51b045 CLI: avoid plugin preload for health --json route (#31108)
* CLI routes: skip plugin preload for health --json

* CLI routes tests: cover health --json plugin preload
2026-03-01 17:13:58 -08:00
Peter Steinberger
155118751f refactor!: remove versioned system-run approval contract 2026-03-02 01:12:53 +00:00
Frank Yang
1636f7ff5f fix(gateway): support wildcard in controlUi.allowedOrigins for remote access (#31088)
* fix(gateway): support wildcard in controlUi.allowedOrigins for remote access

* build: regenerate host env security policy swift

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 01:11:24 +00:00
Shawn
654f63e8f8 fix(signal): prevent sentTranscript sync messages from bypassing loop protection (#31093)
* fix(signal): prevent sentTranscript sync messages from bypassing loop protection

Issue: #31084

On daemon restart, sentTranscript sync messages could bypass loop protection
because the syncMessage check happened before the sender validation. This
reorganizes the checks to:

1. First resolve the sender (phone or UUID)
2. Check if the message is from our own account (both phone and UUID)
3. Only skip sync messages from other sources after confirming not own account

This ensures that sync messages from the own account are properly filtered
to prevent self-reply loops, while still allowing messages synced from other
devices to be processed.

Added optional accountUuid config field for UUID-based account identification.

* fix(signal): cover UUID-only own-message loop protection

* build: regenerate host env security policy swift

---------

Co-authored-by: Kevin Wang <kevin@example.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 01:11:22 +00:00
Peter Steinberger
b9aa2d436b fix(security): enforce sandbox inheritance for sessions_spawn 2026-03-02 01:11:13 +00:00
不做了睡大觉
6a1eedf10b fix: deliver subagent completion announces to Slack without invalid thread_ts (#31105)
* fix(subagent): avoid invalid Slack thread_ts for bound completion announces

* build: regenerate host env security policy swift

---------

Co-authored-by: User <user@example.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 01:11:08 +00:00
Frank Yang
ed86252aa5 fix: handle CLI session expired errors gracefully instead of crashing gateway (#31090)
* fix: handle CLI session expired errors gracefully

- Add session_expired to FailoverReason type
- Add isCliSessionExpiredErrorMessage to detect expired CLI sessions
- Modify runCliAgent to retry with new session when session expires
- Update agentCommand to clear expired session IDs from session store
- Add proper error handling to prevent gateway crashes on expired sessions

Fixes #30986

* fix: add session_expired to AuthProfileFailureReason and missing log import

* fix: type cli-runner usage field to match EmbeddedPiAgentMeta

* fix: harden CLI session-expiry recovery handling

* build: regenerate host env security policy swift

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 01:11:05 +00:00
Peter Steinberger
a95c8077e8 test(discord): type gateway stop mock params 2026-03-02 01:10:03 +00:00
Peter Steinberger
81ca309ee6 fix(agents): land #31002 from @yfge
Co-authored-by: yfge <geyunfei@gmail.com>
2026-03-02 01:08:58 +00:00
Peter Steinberger
250f9e15f5 fix(agents): land #31007 from @HOYALIM
Co-authored-by: Ho Lim <subhoya@gmail.com>
2026-03-02 01:06:00 +00:00
Peter Steinberger
085c23ce5a fix(security): block private-network web_search citation redirects 2026-03-02 01:05:20 +00:00
Peter Steinberger
e1a9ba8400 docs(changelog): credit GHSA-6f6j reporter 2026-03-02 01:04:27 +00:00
Peter Steinberger
c823a85302 fix: harden sandbox media reads against TOCTOU escapes 2026-03-02 01:04:01 +00:00
Peter Steinberger
4320cde91d fix(slack): land #31028 from @taw0002
Co-authored-by: taw0002 <webmaster@sodsolutions.com>
2026-03-02 01:03:39 +00:00
Peter Steinberger
da80e22d89 fix(tools): land #31015 from @haosenwang1018
Co-authored-by: haosenwang1018 <1293965075@qq.com>
2026-03-02 01:01:02 +00:00
Vincent Koc
ac3e1e769b chore(format): swiftformat host env and exec approvals (#31115) 2026-03-01 17:00:17 -08:00
Shakker
81d600d55e fix(protocol): regenerate swift gateway models for internalEvents 2026-03-02 00:55:35 +00:00
Peter Steinberger
c80f34f0e0 test(discord): type gateway stop mock params 2026-03-02 00:49:27 +00:00
Shakker
4274374297 Tests: type Discord gateway lifecycle wait mock 2026-03-02 00:44:34 +00:00
Peter Steinberger
cef5fae0a2 refactor(gateway): dedupe origin seeding and plugin route auth matching 2026-03-02 00:42:22 +00:00
Benedikt Johannes
b81e1b902d Fixes minor security vulnerability (#30948) (#30951)
Merged via squash.

Prepared head SHA: cfbe5fe830
Co-authored-by: benediktjohannes <253604130+benediktjohannes@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-03-02 00:38:01 +00:00
Peter Steinberger
dc816b84ea refactor(matrix): unify startup + split monitor config flow 2026-03-02 00:37:09 +00:00
Vincent Koc
f696b64b51 Doctor: warn when Linux state dir is on SD/eMMC mounts (#31033)
* Doctor state: warn on Linux SD or eMMC state mounts

* Doctor tests: cover Linux SD or eMMC state mount detection

* Docs doctor: document Linux SD or eMMC state warning

* Changelog: add Linux SD or eMMC doctor warning

* Update CHANGELOG.md

* Doctor: escape mountinfo control chars in SD warning

* Doctor tests: cover escaped mountinfo control chars
2026-03-01 16:36:01 -08:00
Peter Steinberger
412eabc42b fix(session): retire stale dm main route after dmScope migration (#31010) 2026-03-02 00:33:54 +00:00
Peter Steinberger
68832f203e refactor(diagnostics): hot-reload stuck warning threshold 2026-03-02 00:32:33 +00:00
Peter Steinberger
fbd832d64f refactor(config): share byte-size parsing for memory flush 2026-03-02 00:32:33 +00:00
Peter Steinberger
9e727893ff refactor(session): consolidate transcript snapshot reads 2026-03-02 00:32:33 +00:00
Peter Steinberger
3a68c56264 refactor(security): unify webhook guardrails across channels 2026-03-02 00:31:42 +00:00
Peter Steinberger
58659b931b fix(gateway): enforce owner boundary for agent runs 2026-03-02 00:27:44 +00:00
Peter Steinberger
9005e8bc0a refactor(gateway): unify metadata canonicalization + platform rules 2026-03-02 00:26:36 +00:00
Peter Steinberger
0c0f556927 fix(discord): unify reconnect watchdog and land #31025/#30530
Landed follow-up intent from contributor PR #31025 (@theotarr) and PR #30530 (@liuxiaopai-ai).

Co-authored-by: theotarr <theotarr@users.noreply.github.com>
Co-authored-by: liuxiaopai-ai <liuxiaopai-ai@users.noreply.github.com>
2026-03-02 00:24:15 +00:00
Peter Steinberger
0eac494db7 fix(gateway): harden node metadata policy classification 2026-03-02 00:15:34 +00:00
Peter Steinberger
84d0a794ec fix: harden matrix startup errors + add regressions (#31023) (thanks @efe-arv) 2026-03-02 00:15:10 +00:00
efe-arv
235ed71e94 fix: handle late client.start() failures via single catch handler
The .catch() handler now covers both early and late failures:
- Within 2s: sets settled=true, startup throws to caller
- After 2s: sets params.state.started=false so subsequent
  resolveSharedMatrixClient() calls detect the dead client

Removed redundant second .catch() — single handler covers all cases.
2026-03-02 00:15:10 +00:00
efe-arv
4f9daf9821 fix: propagate client.start() errors to caller instead of swallowing
Codex review feedback: ensureSharedClientStarted now throws the error
from client.start() if it rejects during the 2s grace window, so
resolveSharedMatrixClient() properly reports failure (e.g. bad token,
unreachable homeserver) instead of leaving the provider in a
running-but-not-syncing state.
2026-03-02 00:15:10 +00:00
efe-arv
8884f99c92 fix: address review feedback — handle start failure, remove placeholder URL
- Don't mark client as started if client.start() errors during init
- Remove placeholder issue URL from comment
2026-03-02 00:15:10 +00:00
efe-arv
f66f563c1a fix(matrix): fix multiple Conduit compatibility issues preventing message delivery
## Changes

### 1. Fix client.start() hanging forever (shared.ts)
The bot-sdk's `client.start()` returns a promise that never resolves
(infinite sync loop). The plugin awaited it, blocking the entire provider
startup — `logged in as` never printed, no messages were processed.

Fix: fire-and-forget with error handler + 2s initialization delay.

### 2. Fix DM false positive for 2-member rooms (direct.ts)
`memberCount === 2` heuristic misclassified explicitly configured group
rooms as DMs when only bot + one user were joined. Messages were routed
through DM policy and silently dropped.

Fix: remove member count heuristic; only trust `m.direct` account data
and `is_direct` room state flag.

Ref: #20145

### 3. Prevent duplicate event listener registration (events.ts)
When both bundled channel plugin and extension load, listeners were
registered twice on the same shared client, causing inconsistent state.

Fix: WeakSet guard to skip registration if client already has listeners.

Ref: #18330

### 4. Add startup grace period (index.ts)
`startupGraceMs = 0` dropped messages timestamped during async setup.
Especially problematic with Conduit which retries on `M_NOT_FOUND`
during filter creation.

Fix: 5-second grace period.

### 5. Fix room ID case sensitivity with Conduit (index.ts)
Room IDs (`!xyz`) without `:server` suffix failed the
`includes(':')` check and were sent to `resolveMatrixTargets`, which
called Conduit's `resolveRoom` — returning lowercased IDs. The bot-sdk
emits events with original-case IDs, causing config lookup mismatches
and reply delivery failures (`M_UNKNOWN: non-create event for room of
unknown version`).

Fix: treat `!`-prefixed entries as room IDs directly (skip resolution).
Only resolve `#alias:server` entries.

## Testing

Tested with Conduit homeserver (lightweight Rust Matrix server).
All fixes verified with gateway log tracing:
- `logged in as @arvi:matrix.local` — first successful login
- `room.message` events fire and reach handler
- Room config matching returns `allowed: true`
- Agent generates response and delivers it to Matrix room
2026-03-02 00:15:10 +00:00
Peter Steinberger
43cad8268d fix(security): harden webhook memory guards across channels 2026-03-02 00:12:05 +00:00
Peter Steinberger
1c8ae978d2 test(lobster): preserve execFile in child_process mock 2026-03-02 00:10:51 +00:00
Peter Steinberger
53d10f8688 fix(gateway): land access/auth/config migration cluster
Land #28960 by @Glucksberg (Tailscale origin auto-allowlist).
Land #29394 by @synchronic1 (allowedOrigins upgrade migration).
Land #29198 by @Mariana-Codebase (plugin HTTP auth guard + route precedence).
Land #30910 by @liuxiaopai-ai (tailscale bind/config.patch guard).

Co-authored-by: Glucksberg <markuscontasul@gmail.com>
Co-authored-by: synchronic1 <synchronic1@users.noreply.github.com>
Co-authored-by: Mariana Sinisterra <mariana.data@outlook.com>
Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
2026-03-02 00:10:51 +00:00
Peter Steinberger
8e6b3ade3e docs(changelog): record session lifecycle and diagnostics fixes 2026-03-02 00:07:47 +00:00
Peter Steinberger
41cc46bbb4 feat(diagnostics): add configurable stuck-session warning threshold 2026-03-02 00:07:29 +00:00
Peter Steinberger
d729ab2150 fix(session): harden usage accounting and memory flush recovery 2026-03-02 00:07:29 +00:00
Vincent Koc
ee96e1751e docs(changelog): add missing contributor credits for 2026.3.1 (#31079)
* changelog: credit @BUGKillerKing for #29315

* changelog: credit @liuweifly for #14674

* changelog: credit @Sid-Qin for #29709

* changelog: credit @lailoo for #21808

* changelog: credit @openperf for #26259

* changelog: credit @icesword0760 for #28959

* changelog: credit @cowboy129 for #28529

* changelog: credit @yfge for #17798

* changelog: credit @kcinzgg for #27325

* changelog: credit @guoqunabc for #28494

* changelog: credit @WilsonLiu95 for #12755

* changelog: credit @qiangu for #18529

* changelog: credit @lailoo for unreleased #27616

* changelog: credit @tumf for unreleased #18642

* changelog: normalize unreleased #24789 credit handle

* changelog: fill unreleased #24435 credit

* changelog: fill unreleased #25090 credit

* changelog: fill unreleased #29098 credit (entry 1)

* changelog: fill unreleased #29098 credit (entry 2)

* changelog: credit @liuxiaopai-ai for unreleased #30567

* changelog: credit @graysurf for unreleased #23169

* changelog: credit @pablohrcarvalho for unreleased #10686

* changelog: credit @Glucksberg for unreleased #21715

* changelog: credit @liuxiaopai-ai for unreleased #30586

* changelog: add missing credits for 2026.2.26

* changelog: add missing credits for 2026.2.25

* changelog: add missing credits for 2026.2.24

* changelog: add missing credits for 2026.2.23

* changelog: add missing credits for 2026.2.22
2026-03-01 16:04:55 -08:00
Peter Steinberger
a62d55b283 test(discord): cover DM command decision flow 2026-03-02 00:00:05 +00:00
Peter Steinberger
75596e9370 refactor(discord): unify DM command auth handling 2026-03-02 00:00:05 +00:00
Peter Steinberger
12c1257023 fix(acpx): share windows wrapper resolver and add strict hardening mode 2026-03-01 23:57:06 +00:00
Peter Steinberger
881ac62005 test(discord): stabilize model picker timeout assertions 2026-03-01 23:53:07 +00:00
Peter Steinberger
ee03ade0d6 fix(agents): harden tool-name normalization and transcript repair
Landed from contributor PRs #30620 and #30735 by @Sid-Qin, plus #30881 by @liuxiaopai-ai.

Co-authored-by: SidQin-cyber <sidqin0410@gmail.com>
Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
2026-03-01 23:51:54 +00:00
Peter Steinberger
50e2674dfc fix(discord): unify dm command auth gating 2026-03-01 23:50:24 +00:00
Peter Steinberger
577becf1ad fix(plugins): prioritize bundled duplicates in auto-discovery
Landed from contributor PR #29710 by @Sid-Qin.

Co-authored-by: SidQin-cyber <sidqin0410@gmail.com>
2026-03-01 23:48:30 +00:00
Peter Steinberger
5056b6438d fix(discord): harden reconnect recovery and preserve message delivery
Landed from contributor PR #29508 by @cgdusek.

Co-authored-by: Charles Dusek <cgdusek@gmail.com>
2026-03-01 23:46:07 +00:00
Peter Steinberger
a0d2f6e4fe docs(changelog): note skills security hardening 2026-03-01 23:45:41 +00:00
Peter Steinberger
23f434f98d fix(skills): constrain plugin skill paths 2026-03-01 23:45:41 +00:00
Peter Steinberger
4614222572 fix(skills): validate installer metadata specs 2026-03-01 23:45:41 +00:00
edincampara
577f2fa540 fix(docker): harden /app/extensions permissions to 755 (#30191)
* fix(docker): harden /app/extensions permissions to 755

Bundled extension directories shipped as world-writable (mode 777)
in the Docker image. The plugin security scanner blocks any world-
writable path with:

  WARN: blocked plugin candidate: world-writable path
        (/app/extensions/memory-core, mode=777)

Add chmod -R 755 /app/extensions in the final USER root RUN step so
all bundled extensions are readable but not world-writable. This runs
as root before switching back to the node user, matching the pattern
already used for chmod 755 /app/openclaw.mjs.

Fixes #30139

* fix(docker): normalize plugin and agent path permissions

* docs(changelog): add docker permissions entry for #30191

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-01 15:45:21 -08:00
Peter Steinberger
9e6e7a3d69 fix(acpx): harden windows cmd wrapper spawning 2026-03-01 23:44:36 +00:00
Peter Steinberger
13bb80df9d fix(agents): land #20840 cross-channel message-tool actions from @altaywtf
Include scoped cross-channel action/description behavior, regression tests, changelog note, and make Ollama discovery tests URL-scoped to avoid env-dependent fetch interference.

Co-authored-by: Altay <altay@hey.com>
2026-03-01 23:37:55 +00:00
Peter Steinberger
912ddba81e fix(macos): harden exec approvals socket path and permissions 2026-03-01 23:37:11 +00:00
Peter Steinberger
6c5633598e fix(security): harden clawlog command execution 2026-03-01 23:33:13 +00:00
Peter Steinberger
ccb415b69a fix: align ACP permission docs defaults (#31044) (thanks @barronlroth) 2026-03-01 23:30:39 +00:00
Barron Roth
bed1cb9600 docs(acp): add permission configuration section and troubleshooting entries
Document permissionMode and nonInteractivePermissions plugin config
keys for the acpx backend. Add troubleshooting entries for:
- Permission prompt errors in non-interactive ACP sessions
- Silent session failures from swallowed permission errors
- Stalled ACP sessions that never report completion

Relates to #29195

AI-assisted (lightly tested)
2026-03-01 23:30:39 +00:00
Peter Steinberger
6a80e9db05 fix(browser): harden writable output paths 2026-03-01 23:25:13 +00:00
Peter Steinberger
51bccaf988 chore(changelog): note internal events and ingress hardening 2026-03-01 23:12:09 +00:00
Peter Steinberger
b99666a47a fix(security): harden inbound metadata sentinel stripping 2026-03-01 23:11:48 +00:00
Peter Steinberger
8e48520d74 fix(channels): align command-body parsing sources 2026-03-01 23:11:48 +00:00
Peter Steinberger
4c43fccb3e feat(agents): use structured internal completion events 2026-03-01 23:11:48 +00:00
Peter Steinberger
738dd9aa42 fix(agents): type openai websocket warmup passthrough 2026-03-01 23:10:08 +00:00
Vincent Koc
eb20793550 Docs: add all unlisted docs routes to navigation (#31027)
* Docs: add missing platform pages to nav

* Docs: include all unlisted docs routes in nav

* Docs nav: classify routes by area and remove catch-all groups

* Docs nav: remove ja-JP AGENTS page entry

* Docs ja-JP: remove AGENTS translation workspace page

* Docs nav: remove refactor plans group

* Docs nav: remove .dev template pages

* Docs nav: remove operations hubs group
2026-03-01 15:09:35 -08:00
Peter Steinberger
0f5348acb2 test(config): reject discord open DM with empty allowFrom 2026-03-01 23:08:37 +00:00
Peter Steinberger
d1615eb35f feat(openai): add websocket warm-up with configurable toggle 2026-03-01 22:45:03 +00:00
Agent
bc9f357ad7 test: fix fetch mock typing casts 2026-03-01 22:44:28 +00:00
Agent
002539c01e fix(security): harden sandbox novnc observer flow 2026-03-01 22:44:28 +00:00
Peter Steinberger
4ab13eca4d test(agents): port OpenAI websocket coverage from #24911
Co-authored-by: Jonathan Jing <achillesjing@gmail.com>
2026-03-01 22:38:56 +00:00
Vincent Koc
eee870576d doctor: warn on macOS cloud-synced state directories (#31004)
* Doctor: detect macOS cloud-synced state directories

* Doctor tests: cover cloud-synced macOS state detection

* Docs: note cloud-synced state warning in doctor guide

* Docs: recommend local macOS state dir placement

* Changelog: add macOS cloud-synced state dir warning

* Changelog: credit macOS cloud state warning PR

* Doctor state: anchor cloud-sync roots to macOS home

* Doctor tests: cover OPENCLAW_HOME cloud-sync override

* Doctor state: prefer resolved target for cloud detection

* Doctor tests: cover local-target cloud symlink case
2026-03-01 14:35:46 -08:00
Agent
063c4f00ea docs: clarify Anthropic context1m long-context requirements 2026-03-01 22:35:26 +00:00
Agent
a374325fc2 docs(security): clarify local link-priming reports as out-of-scope 2026-03-01 22:34:32 +00:00
Peter Steinberger
8da86f6995 chore(changelog): note openai websocket-first streaming 2026-03-01 22:33:21 +00:00
Peter Steinberger
7ced38b5ef feat(agents): make openai responses websocket-first with fallback 2026-03-01 22:32:37 +00:00
Vincent Koc
38da2d076c CLI: add root --help fast path and lazy channel option resolution (#30975)
* CLI argv: add strict root help invocation guard

* Entry: add root help fast-path bootstrap bypass

* CLI context: lazily resolve channel options

* CLI context tests: cover lazy channel option resolution

* CLI argv tests: cover root help invocation detection

* Changelog: note additional startup path optimizations

* Changelog: split startup follow-up into #30975 entry

* CLI channel options: load precomputed startup metadata

* CLI channel options tests: cover precomputed metadata path

* Build: generate CLI startup metadata during build

* Build script: invoke CLI startup metadata generator

* CLI routes: preload plugins for routed health

* CLI routes tests: assert health plugin preload

* CLI: add experimental bundled entry and snapshot helper

* Tools: compare CLI startup entries in benchmark script

* Docs: add startup tuning notes for Pi and VM hosts

* CLI: drop bundled entry runtime toggle

* Build: remove bundled and snapshot scripts

* Tools: remove bundled-entry benchmark shortcut

* Docs: remove bundled startup bench examples

* Docs: remove Pi bundled entry mention

* Docs: remove VM bundled entry mention

* Changelog: remove bundled startup follow-up claims

* Build: remove snapshot helper script

* Build: remove CLI bundle tsdown config

* Doctor: add low-power startup optimization hints

* Doctor: run startup optimization hint checks

* Doctor tests: cover startup optimization host targeting

* Doctor tests: mock startup optimization note export

* CLI argv: require strict root-only help fast path

* CLI argv tests: cover mixed root-help invocations

* CLI channel options: merge metadata with runtime catalog

* CLI channel options tests: assert dynamic catalog merge

* Changelog: align #30975 startup follow-up scope

* Docs tests: remove secondary-entry startup bench note

* Docs Pi: add systemd recovery reference link

* Docs VPS: add systemd recovery reference link
2026-03-01 14:23:46 -08:00
Agent
dcd19da425 refactor: simplify sandbox boundary open flow 2026-03-01 21:49:42 +00:00
Agent
3be1343e00 fix: tighten sandbox mkdirp boundary checks (#30610) (thanks @glitch418x) 2026-03-01 21:41:47 +00:00
glitch418x
687f5779d1 sandbox: allow directory boundary checks for mkdirp 2026-03-01 21:41:47 +00:00
Bob
4fc7ecf088 ACP: force sessions_spawn as the only harness thread creation path (#30957)
* ACP: enforce sessions_spawn-only thread creation for harness spawns

* skills(acpx): require acp-router preflight for ACP thread spawns

* fix: enforce ACP thread spawn via sessions_spawn only (#30957) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-01 22:41:06 +01:00
Agent
e4d22fb07a fix(browser): fail closed browser auth bootstrap 2026-03-01 21:40:16 +00:00
Agent
3a93a7bb1e fix(security): enforce auth for abort triggers and models 2026-03-01 21:30:07 +00:00
Peter Steinberger
c89836a251 test: harden flaky timeout and resolver specs 2026-03-01 21:30:07 +00:00
Sid
c1428e8df9 fix(gateway): prevent /api/* routes from returning SPA HTML when basePath is empty (#30333)
Merged via squash.

Prepared head SHA: 12591f304e
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-01 22:23:54 +01:00
Vincent Koc
e6049345db fix(telegram): preserve HTTP proxy env in global dispatcher workaround (#29940)
* fix(telegram): preserve HTTP proxy env in global dispatcher workaround

* telegram: document request-scoped proxy dispatcher constraint

* telegram: assert proxy path never mutates global dispatcher

* changelog: credit telegram proxy env regression fix

---------

Co-authored-by: Rylen Anil <rylen.anil@gmail.com>
2026-03-01 13:21:01 -08:00
Agent
e7cafed424 chore(release): bump version to 2026.3.1 2026-03-01 21:14:17 +00:00
Vincent Koc
94a5d28d26 CI: remove Vitest JSON report artifacts (#30976)
* CI: remove vitest JSON report upload steps

* Tests: stop injecting vitest JSON reporter

* Tests: remove vitest slowest report script
2026-03-01 13:03:06 -08:00
Vincent Koc
79f818e8a2 Status scan: guard deferred promise rejections 2026-03-01 12:56:56 -08:00
Vincent Koc
125ea585dd CLI routes tests: assert status plugin preload 2026-03-01 12:56:56 -08:00
Vincent Koc
266084f4c8 CLI routes: preload plugins for status security parity 2026-03-01 12:56:56 -08:00
Vincent Koc
4b027927cf Changelog: credit startup performance reports 2026-03-01 12:56:56 -08:00
Vincent Koc
23c6e9836e Status scan: overlap non-JSON async checks 2026-03-01 12:56:56 -08:00
Vincent Koc
c161e141f3 Docs tests: add CLI startup benchmark usage 2026-03-01 12:56:56 -08:00
Vincent Koc
bdd59e0149 Scripts: add CLI startup benchmark harness 2026-03-01 12:56:56 -08:00
Vincent Koc
08ea7f0cf6 Docs VPS: add startup tuning for small hosts 2026-03-01 12:56:56 -08:00
Vincent Koc
86e4f3e7e2 Docs Pi: add startup tuning for compile cache 2026-03-01 12:56:56 -08:00
Vincent Koc
8c4071f36a Entry: enable Node compile cache on startup 2026-03-01 12:56:56 -08:00
Vincent Koc
e4b4fd5ce8 Entry: avoid top-level return in version fast-path 2026-03-01 12:56:56 -08:00
Vincent Koc
7aa9267d00 Status scan: fix JSON channels result typing 2026-03-01 12:56:56 -08:00
Vincent Koc
ba0aa3cfae Status scan: add parallel JSON fast path 2026-03-01 12:56:56 -08:00
Vincent Koc
b0a73ae773 Status command: parallelize JSON security audit 2026-03-01 12:56:56 -08:00
Vincent Koc
07da843378 CLI argv: test root version fast-path detection 2026-03-01 12:56:56 -08:00
Vincent Koc
153adc4c8f Entry: fast-path root version command 2026-03-01 12:56:56 -08:00
Vincent Koc
86a91cc01a CLI argv: detect root-only version invocation 2026-03-01 12:56:56 -08:00
Vincent Koc
3c4cdf72c9 CLI routes: test conditional plugin preload behavior 2026-03-01 12:56:56 -08:00
Vincent Koc
22653c0e27 Status scan: skip channel table work in JSON mode 2026-03-01 12:56:56 -08:00
Vincent Koc
af12e7bdec CLI route: support argv-aware plugin preloading 2026-03-01 12:56:56 -08:00
Vincent Koc
5e061fd8b9 CLI routes: skip plugin preload for health 2026-03-01 12:56:56 -08:00
Ben Gitter
5d7314db22 fix(control-ui): include basePath in default WebSocket URL (#30228)
Merged via squash.

Prepared head SHA: a56d8d441c
Co-authored-by: gittb <8284364+gittb@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-01 21:01:43 +01:00
Onur Solmaz
907c09e1d5 fix: add changelog entry for ACPX stream update (#30036) (thanks @osolmaz) 2026-03-01 20:39:24 +01:00
Onur
b12c909ea2 ACPX: pin 0.1.15 and tolerate missing --version in health check 2026-03-01 20:39:24 +01:00
Onur
f81c2e75d2 Tests: make acpx config path assertions cross-platform 2026-03-01 20:39:24 +01:00
Onur
ac5d7ee4cd Tests: normalize HOME expansion assertion on Windows 2026-03-01 20:39:24 +01:00
Onur
18033d3962 Cron+Slack: fix cooldown omission and cache cap enforcement 2026-03-01 20:39:24 +01:00
Onur
8292401719 ACP: rename stream char limits to output/sessionUpdate 2026-03-01 20:39:24 +01:00
Onur
4664d13857 Docs: remove temp ACP planning files 2026-03-01 20:39:24 +01:00
Onur
053e5eb506 ACP: remove maxMetaEventsPerTurn limit 2026-03-01 20:39:24 +01:00
Onur
6c08652c8d Tests: use preferred tmp dir in acpx runtime fixtures 2026-03-01 20:39:24 +01:00
Onur
ca31683ca3 Tests: fix dispatch-acp mock typings for tsgo 2026-03-01 20:39:24 +01:00
Onur
63e607db9b ACPX: pin plugin dependency to 0.1.14 2026-03-01 20:39:24 +01:00
Onur
f4538b22f7 ACP: fix projector dedupe regressions 2026-03-01 20:39:24 +01:00
Onur
be73eb28b3 ACP: improve live text batching readability 2026-03-01 20:39:24 +01:00
Onur
dd2fcade3e ACP: make live mode flush incremental chunks 2026-03-01 20:39:24 +01:00
Onur
43c57005a6 ACP: start typing lifecycle at turn start and harden delivery 2026-03-01 20:39:24 +01:00
Onur
c8b958e573 ACP: add hidden-boundary separator for hidden tool events 2026-03-01 20:39:24 +01:00
Onur
acd6ddb829 ACP: hide tool_call tags by default 2026-03-01 20:39:24 +01:00
Onur
5232f96d59 Agents: use tool emoji for ACP tool_call summaries 2026-03-01 20:39:24 +01:00
Onur
4324d84edd Docs: add ACP thread tool-editing final-only implementation plan 2026-03-01 20:39:24 +01:00
Onur
c3a1fe01ae ACP: make final_only defer all projected output 2026-03-01 20:39:24 +01:00
Onur
4a82012461 ACP: default stream delivery to final_only 2026-03-01 20:39:24 +01:00
Onur
4e2efaf659 ACP: simplify stream config to repeatSuppression 2026-03-01 20:39:24 +01:00
Onur
79fcc8404e Scripts: add openclaw driver mode to discord ACP smoke 2026-03-01 20:39:24 +01:00
Onur
752398a6ba Refactor: split ACP dispatch delivery and settings 2026-03-01 20:39:24 +01:00
Onur
54ed2efc20 Tests: complete ACP meta dedupe coverage 2026-03-01 20:39:24 +01:00
Onur
9cfc630be9 ACPX: sync main ACP parser changes onto configurable-command branch 2026-03-01 20:39:24 +01:00
Onur
cf3e4d2aef Docs: restore ACP meta-event dedupe implementation plan 2026-03-01 20:39:24 +01:00
Onur
2466a9bb13 ACP: carry dedupe/projector updates onto configurable acpx branch 2026-03-01 20:39:24 +01:00
Onur
f88bc09f85 ACPX: ignore replayed updates outside active prompt 2026-03-01 20:39:24 +01:00
Onur
d669b27a45 ACPX extension: split ACP stream parser and test fixtures 2026-03-01 20:39:24 +01:00
Onur
bdc355d0b0 ACPX extension: parse pure ACP JSON-RPC stream 2026-03-01 20:39:24 +01:00
Onur
9cae5107d1 ACPX extension: support acpx any-version probe via --help 2026-03-01 20:39:24 +01:00
Onur
921ebfb25e ACPX plugin: allow configurable command and expected version 2026-03-01 20:39:24 +01:00
Glucksberg
134296276a fix(memory): discard stdout for qmd update/embed to prevent output cap failure (openclaw#28900) thanks @Glucksberg
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 12:16:50 -06:00
pablohrcarvalho
11d34700c0 fix(slack): use thread-level sessions for channels to prevent context mixing (#10686)
* fix(slack): use thread-level sessions for channels to prevent context mixing

All messages in a Slack channel share a single session, causing context from
different threads to mix together. When users have multiple conversations in
different threads of the same channel, the agent sees combined context from
all threads, leading to confused responses.

Session key was: `slack:channel:${channelId}` (no thread identifier)

1. **Thread-level session keys**: Each message in channels/groups now gets
   its own session based on thread_ts:
   - Thread replies: use the parent thread's ts
   - New messages: use the message's own ts (becomes thread root)
   - DMs: unchanged (no thread-level sessions needed)

   New session key format: `slack:channel:${channelId}🧵${threadTs}`

2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours.
   Users often pause conversations, and the short TTL caused unnecessary
   API calls and thread resolution failures.

3. **Increased cache size**: Changed from 500 to 10,000 entries to support
   busy workspaces with many active threads.

1. Create two threads in the same Slack channel
2. In Thread A: tell the bot your name is "Alice" and ask about "billing"
3. In Thread B: tell the bot your name is "Bob" and ask about "API"
4. Reply in Thread A and ask "what's my name?" - should say "Alice"
5. Check sessions: each thread should have a unique session key with 🧵 suffix

Fixes context bleed issues related to #758

* fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions

The context.ts file has a separate function for resolving session keys for
system events (reactions, file uploads, etc.). This also needs to support
thread-level sessions to ensure all Slack events route to the correct
thread-specific session.

Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey
and updated the implementation to use thread-level keys for channels/groups.

* fix(slack): preserve DM thread sessions for thread replies

The previous change broke thread-level sessions for DMs that have threads.
DMs with parent_user_id should still get thread-level sessions.

- For channels/groups: always use thread-level sessions
- For DMs: use thread-level sessions only when isThreadReply is true

* fix(slack): use thread-level sessionKey for previousTimestamp

Fixes the bug where previousTimestamp was read from the base channel
session key (route.sessionKey) instead of the resolved thread-level
sessionKey. This caused the elapsed-time calculation in the inbound
envelope to always pull from the channel session rather than the
thread session.

Also adds regression tests for the thread-level session key behavior.

Co-authored-by: Tony Dehnke <tdehnke@gmail.com>

* fix(slack): narrow #10686 to surgical thread-session patch

* test(slack): satisfy context/account typing in thread-session tests

* docs(changelog): record surgical slack thread-session fix

---------

Co-authored-by: Pablo Carvalho <pablo@telnyx.com>
Co-authored-by: Tony Dehnke <tdehnke@gmail.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 12:04:57 -06:00
Tak Hoffman
a179a0d371 Slack onboarding: improve token help note with manifest option (openclaw#30846) thanks @yzhong52
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: yzhong52 <3712071+yzhong52@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:57:30 -06:00
msvechla
2c5b898eea feat(slack): add download-file action for on-demand file attachment access (#24723)
* feat(slack): add download-file action for on-demand file attachment access

Adds a new `download-file` message tool action that allows the agent to
download Slack file attachments by file ID on demand. This is a prerequisite
for accessing images posted in thread history, where file attachments are
not automatically resolved.

Changes:
- Add `files` field to `SlackMessageSummary` type so file IDs are
  visible in message read results
- Add `downloadSlackFile()` to fetch a file by ID via `files.info`
  and resolve it through the existing `resolveSlackMedia()` pipeline
- Register `download-file` in `CHANNEL_MESSAGE_ACTION_NAMES`,
  `MESSAGE_ACTION_TARGET_MODE`, and `listSlackMessageActions`
- Add `downloadFile` dispatch case in `handleSlackAction`
- Wire agent-facing `download-file` → internal `downloadFile` in
  `handleSlackMessageAction`

Closes #24681

* style: fix formatting in slack-actions and actions

* test(slack): cover download-file action path

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:45:05 -06:00
graysurf
eddaf19478 fix(slack): guard allow-from store resolution in monitor auth (#21967) 2026-03-01 11:42:58 -06:00
Oleksandr Zakotyanskyi
2a409bbba0 fix(slack): replace files.uploadV2 with 3-step upload flow to fix missing_scope error (#17558)
* fix(slack): replace files.uploadV2 with 3-step upload flow

files.uploadV2 from @slack/web-api internally calls the deprecated
files.upload endpoint, which fails with missing_scope even when
files:write is correctly granted in the bot token scopes.

Replace with Slack's recommended 3-step upload flow:
1. files.getUploadURLExternal - get presigned URL + file_id
2. fetch(upload_url) - upload file content
3. files.completeUploadExternal - finalize & share to channel/thread

This preserves all existing behavior including thread replies via
thread_ts and caption via initial_comment.

* fix(slack): harden external upload flow and tests

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:37:18 -06:00
Sid
39a45121d9 fix(discord,slack): add SSRF policy for media downloads in proxy environments (#25475)
* fix(discord,slack): add SSRF policy for media downloads in proxy environments

Discord and Slack media downloads (attachments, stickers, forwarded
images) call fetchRemoteMedia without any ssrfPolicy. When running
behind a local transparent proxy (Clash, mihomo, Shadowrocket) in
fake-ip mode, DNS returns virtual IPs in the 198.18.0.0/15 range,
which the SSRF guard blocks.

Add per-channel SSRF policy constants—matching the pattern already
applied to Telegram on main—that allowlist known CDN hostnames and
set allowRfc2544BenchmarkRange: true.

Refs #25355, #25322

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(slack): keep raw-fetch allowlist line anchors stable

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:30:10 -06:00
Jc Miñarro
b9e07ad7b4 docs(slack): add missing DM scopes to manifest (openclaw#29999) thanks @JcMinarro
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: JcMinarro <4047514+JcMinarro@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:25:52 -06:00
calder-sandy
93ac2b43fb feat(slack): per-thread session isolation for DM auto-threading (#26849)
* feat(slack): create thread sessions for auto-threaded DM messages

When replyToMode="all", every top-level message starts a new Slack thread.
Previously, only subsequent replies in that thread got an isolated session
(via 🧵<threadTs> suffix). The initial message fell back to the base
DM session, mixing context across unrelated conversations.

Now, when replyToMode="all" and a message is not already a thread reply,
the message's own ts is used as the threadId for session key resolution.
This gives the initial message AND all subsequent thread replies the same
isolated session.

This enables per-thread session isolation for Slack DMs — each new message
starts its own thread and session, keeping conversations separate.

* Slack: fix auto-thread session key mode check and add changelog

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:24:45 -06:00
Jin Kim
746688ddc9 Slack: redact and cap interaction system events (#28982) 2026-03-01 11:24:43 -06:00
tumf
e0571399ac fix(slack): reject HTML responses when downloading media (#4665)
* fix(slack): reject HTML responses when downloading media

Slack sometimes returns HTML login pages instead of binary media when
authentication fails or URLs expire. This change detects HTML responses
by checking content-type header and buffer content, then skips to the
next available file URL.

* fix: format import order and add braces to continue statement

* chore: format Slack media tests

* chore: apply formatter to Slack media tests

* fix(slack): merge auth-header forwarding and html media guard

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:20:25 -06:00
Glucksberg
6dbbc58a8d fix(slack): use SLACK_USER_TOKEN when connecting to Slack (#28103)
* fix(slack): use SLACK_USER_TOKEN when connecting to Slack (closes #26480)

* test(slack): fix account fixture typing for user token source

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:05:35 -06:00
dan bachelder
46da76e267 fix(slack): honor replyToModeByChatType when ThreadLabel exists (#26251)
* fix(slack): honor direct replyToMode when thread label exists

ThreadLabel is a session/conversation label, not a reliable indicator
of an actual Slack thread reply. Using it to force replyToMode="all"
overrides replyToModeByChatType.direct="off" in DMs.

Switch to MessageThreadId which indicates a real thread target is
available, preserving expected behavior: thread replies stay threaded,
normal DMs respect the configured mode.

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

* Slack: add changelog for threading tool context fix

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:02:06 -06:00
Dennis Rankin
a28a4b1b61 feat: detect stale Slack sockets and auto-restart (#30153)
* feat: detect stale Slack sockets and auto-restart

Slack Socket Mode connections can silently stop delivering events while
still appearing connected (health checks pass, WebSocket stays open).
This "half-dead socket" problem causes messages to go unanswered.

This commit adds two layers of protection:

1. **Event liveness tracking**: Every inbound Slack event (messages,
   reactions, member joins/leaves, channel events, pins) now calls
   `setStatus({ lastEventAt, lastInboundAt })` to update the channel
   account snapshot with the timestamp of the last received event.

2. **Health monitor stale socket detection**: The channel health monitor
   now checks `lastEventAt` against a configurable threshold (default
   30 minutes). If a channel has been running longer than the threshold
   and hasn't received any events in that window, it is flagged as
   unhealthy and automatically restarted — the same way disconnected
   or crashed channels are already handled.

The restart reason is logged as "stale-socket" for observability, and
the existing cooldown/rate-limit logic (3 restarts/hour max) prevents
restart storms.

* Slack: gate liveness tracking to accepted events

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:58:21 -06:00
lailoo
43ddb41354 fix(slack): extract attachment text for bot messages with empty text (#27616) (#27642)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
2026-03-01 10:49:51 -06:00
Miguel Miranda Dias
949faff5ce fix(slack): reconnect socket mode after disconnect (#27232)
* fix(slack): reconnect socket mode after disconnect

* fix(slack): avoid orphaned disconnect waiters on start failure

* docs(changelog): record slack socket reconnect reliability fix

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:42:45 -06:00
graysurf
a54b85822c Handle transient Slack request errors without crashing the gateway (openclaw#23787) thanks @graysurf
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: graysurf <10785178+graysurf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:42:42 -06:00
Luis Conde
bd78a74298 feat(slack): track thread participation for auto-reply without @mention (#29165)
* feat(slack): track thread participation for auto-reply without @mention

* fix(slack): scope thread participation cache by accountId and capture actual reply thread ts

* fix(slack): capture reply thread ts from all delivery paths and only after success

* Slack: add changelog for thread participation cache behavior

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:42:12 -06:00
Peter Machona
dfbdab5a29 fix(slack): map legacy streaming=false to off (openclaw#26020) thanks @chilu18
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: chilu18 <7957943+chilu18@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:21:25 -06:00
dan bachelder
9ae94390b9 fix(slack): resolve replyToMode per-message using chat type (#24717)
* fix(slack): resolve replyToMode per-message using chat type

The Slack monitor resolved replyToMode once at startup from the
top-level config, ignoring replyToModeByChatType overrides. This caused
DM replies to be threaded even when replyToModeByChatType.direct was
set to "off".

Now the inbound message handler calls resolveSlackReplyToMode(account,
chatType) per-message — the same function already used by the outbound
dock and tool threading context — so per-chat-type overrides take
effect on the inbound path.

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

* Slack: add changelog for per-message replyToMode resolution

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:21:01 -06:00
Mark L
265b22c401 fix(slack): skip monitor startup for disabled accounts [AI-assisted] (openclaw#30592) thanks @liuxiaopai-ai
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 10:19:50 -06:00
François Martin
53d6e07a60 fix(sessions): set transcriptPath to agent sessions directory (openclaw#24775) thanks @martinfrancois
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: martinfrancois <14319020+martinfrancois@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:41:06 -06:00
Colin Johnson
0f36ee5a2e Slack: harden slash and interactions ingress checks (openclaw#29091) thanks @Solvely-Colin
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:40:57 -06:00
Glucksberg
3aad6c8bdb fix(slack): guard Socket Mode listeners access during startup (openclaw#28702) thanks @Glucksberg
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:29:18 -06:00
HouRong
b3f60a68a0 fix(slack): thread agent identity through channel reply path (openclaw#27134) thanks @hou-rong
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: hou-rong <8758438+hou-rong@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:25:32 -06:00
pushkarsingh32
4ba0a4d4fb fix(slack): wrap session key in backticks to prevent emoji shortcode parsing (openclaw#30266) thanks @pushkarsingh32
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: pushkarsingh32 <29558481+pushkarsingh32@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:23:50 -06:00
Tak Hoffman
ff563eef0f Issues: unify bug form and subtype auto-labeling (openclaw#30733) thanks @Takhoffman
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:20:57 -06:00
Ayaan Zaidi
201c6252ed test(slack): pass cfg to buildAccountSnapshot in tests 2026-03-01 20:36:05 +05:30
Ayaan Zaidi
08f98ac1c9 docs(changelog): note android notify auth-race fix (#30726) 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
6f63fc288a fix(android): return NOT_AUTHORIZED when notify permission is lost 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
0d672e43b9 chore(protocol): sync generated swift models 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
759a0fc1b2 chore(android): remove deprecated AGP gradle flags 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
9c2f7e2a9d style(android): format gradle kotlin scripts 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
348a7dd5b3 fix(android): guard notification post permission 2026-03-01 20:32:14 +05:30
Ayaan Zaidi
7f9274b71d chore(android): add kotlin lint/format tooling 2026-03-01 20:32:14 +05:30
Mark L
4da4cc94c1 fix(slack): treat HTTP mode accounts as configured [AI-assisted] (openclaw#30571) thanks @liuxiaopai-ai
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 09:00:17 -06:00
Xu Gu
e3ba59dc71 Control UI: add cron jobs schedule/status filters with reset (#9510)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 08:49:11 -06:00
Ayaan Zaidi
59fd394bfe docs(changelog): add #29521 voice tts entry (thanks @gregmousseau) 2026-03-01 20:03:26 +05:30
Greg Mousseau
ba430cc65b fix(android): drainingTts identity check, mark stopped on WebSocket failure
- Codex P2: drain coroutine now only clears drainingTts if it's the
  same instance (=== check), preventing a newer drain from being
  unreachable by stopTts.
- Codex P2: set stopped=true on WebSocket onFailure so subsequent
  sendText calls are rejected and stale state doesn't persist.
2026-03-01 20:03:26 +05:30
Greg Mousseau
ccca99c472 fix(android): ignore stale out-of-order agent events in streaming TTS
Agent events arrive on multiple threads concurrently. A stale event
with shorter accumulated text was falsely triggering 'text diverged',
causing the streaming TTS to restart with a new WebSocket — resulting
in multiple simultaneous ElevenLabs connections (2-3 voices) and
eventual system TTS fallback when hasReceivedAudio was false.

Fix: if sentFullText.startsWith(fullText), the event is stale (we
already have this text), not diverged. Accept and ignore it.
2026-03-01 20:03:26 +05:30
Greg Mousseau
a583261775 fix(android): wire speaker mute to TalkMode, release audio focus on stop
- Codex P1: setSpeakerEnabled now syncs talkMode.setPlaybackEnabled
  so muting the speaker works when ttsOnAllResponses is active.
- Codex P2: abandonAudioFocus() called in stopSpeaking to prevent
  audio focus leak after TTS completes or is interrupted.
2026-03-01 20:03:26 +05:30
Greg Mousseau
930841cd7c fix(android): wire MP3 fallback call, prevent double-speaking
- Codex P1: streamAndPlayMp3 was computed but never called after PCM
  failure. Now properly invoked as fallback.
- Codex P2: MicCaptureManager.speakAssistantReply now skipped when
  TalkModeManager.ttsOnAllResponses is active, preventing both
  pipelines from speaking the same assistant reply.
2026-03-01 20:03:26 +05:30
Greg Mousseau
587790e84a fix(android): talk mode stability — thread safety, TTS fallback, mic cooldown
Bug fixes:
- @Synchronized on ElevenLabsStreamingTts.sendText/finish to prevent
  sentFullText/sentTextLength races across OkHttp and caller threads
- Pre-set pendingRunId via onRunIdKnown callback before chat.send to
  eliminate race where gateway events arrive before runId is stored
- Track drain coroutine as Job; cancel prior on rapid mic toggle to
  prevent duplicate TTS and stale transcript sends
- Mic button disabled during 2s drain cooldown (micCooldown StateFlow)

Codex review fixes:
- Gate agent streaming TTS on sessionKey to prevent cross-session
  audio leaks (P1)
- Clear ElevenLabs credentials when talk.provider is not elevenlabs;
  gate streaming TTS on activeProviderIsElevenLabs (P2)

System TTS fallback fixes:
- Null streamingTts immediately in finishStreamingTts so next response
  gets a fresh TTS instance
- Add hasReceivedAudio flag to ElevenLabsStreamingTts to detect when
  WebSocket connects but returns no audio (invalid key, network error)
- Fall back to playTtsForText when streaming TTS produced no audio
- Track ttsJob to cleanly cancel prior playTtsForText on new response
- Re-throw CancellationException instead of cascading into fallback
  attempts that also get cancelled
2026-03-01 20:03:26 +05:30
Greg Mousseau
4748ba491d fix(android): chat history refresh and mic capture improvements for voice
ChatController:
- final/aborted/error run events now trigger a history refresh regardless of
  whether the runId is in pendingRuns; only delta events require the run to be
  tracked (prevents voice-initiated responses from being silently dropped)

MicCaptureManager:
- Don't auto-send on onResults silence detection — accumulate transcript
  segments and send when mic is toggled off, giving the recognizer time to
  finish processing buffered audio
- Capture any partial live transcript if no final segments arrived (2s drain
  window before stop)
- Join multi-segment transcripts with sentence-ending punctuation to avoid
  run-on text sent to the gateway
2026-03-01 20:03:26 +05:30
Greg Mousseau
68db055f1a feat(android): wire TalkModeManager into NodeRuntime for voice screen TTS
TalkModeManager is instantiated lazily in NodeRuntime and drives ElevenLabs
streaming TTS for all assistant responses when the voice screen is active.
MicCaptureManager continues to own STT and chat.send; TalkModeManager is
TTS-only (ttsOnAllResponses = true, setEnabled never called).

- talkMode.ttsOnAllResponses = true when mic is enabled or voice screen active
- Barge-in: tapping the mic button calls stopTts() before re-enabling mic
- Lifecycle: PostOnboardingTabs LaunchedEffect + VoiceTabScreen onDispose both
  call setVoiceScreenActive(false) so TTS stops cleanly on tab switch or
  app backgrounding
- applyMainSessionKey wires the session key into TalkModeManager so it
  subscribes to the correct chat session for TTS
2026-03-01 20:03:26 +05:30
Greg Mousseau
f0fcecd7c1 feat(android): ElevenLabsStreamingTts — WebSocket streaming TTS with PCM playback
Streams text to the ElevenLabs WebSocket API and plays audio in real-time
via AudioTrack (PCM 24kHz). Key design points:

- sendText(fullText) takes the full accumulated text and only transmits the
  new suffix, detecting divergence for restart
- Chunks are queued if the WebSocket isn't yet connected; flushed in onOpen
- finish() sends EOS to ElevenLabs; deferred if called before onOpen fires
- sendText returns true (not false) when finished=true to avoid treating a
  normal end-of-stream as a diverge restart
- finishStreamingTts coroutine uses identity check before nulling streamingTts
  to prevent a mid-drain restart from orphaning a live TTS session
- eleven_v3 does NOT support WebSocket streaming; use eleven_flash_v2_5
2026-03-01 20:03:26 +05:30
Ian Derrington
266d320062 feat(ui): add hide-cron toggle to chat session selector (#26976)
* feat(ui): add hide-cron toggle to chat session selector

Adds a clock icon toggle button in the chat controls bar that filters
cron sessions out of the session dropdown. Default: hidden (true).

Why: cron sessions (key prefix `cron:`) accumulate fast — a job running
every 15 min produces 48 entries/day. They pollute the session selector
on small screens and devices like the Rabbit R1.

Changes:
- app-render.helpers.ts
  - isCronSessionKey() — exported helper (exported for tests)
  - countHiddenCronSessions() — counts filterable crons, skips active key
  - resolveSessionOptions() — new hideCron param; skips cron: keys
    unless that key is the currently active session (never drop it)
  - renderCronFilterIcon() — clock SVG with optional badge count
  - renderChatControls() — reads state.sessionsHideCron (default true),
    passes hideCron to resolveSessionOptions, adds toggle button at the
    end of the controls bar showing hidden count as a badge
- app-view-state.ts — adds sessionsHideCron: boolean to AppViewState
- app.ts — @state() sessionsHideCron = true (persists across re-renders)
- app-render.helpers.node.test.ts — tests for isCronSessionKey

* fix(ui): harden cron session filtering and i18n labels

---------

Co-authored-by: FLUX <flux@openclaw.ai>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 08:24:14 -06:00
0xbrak
4637b90c07 feat(cron): configurable failure alerts for repeated job errors (openclaw#24789) thanks @0xbrak
Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/cron/service.failure-alert.test.ts src/cli/cron-cli.test.ts src/gateway/protocol/cron-validators.test.ts

Co-authored-by: 0xbrak <181251288+0xbrak@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 08:18:15 -06:00
yinghaosang
f902697bd5 feat(cron): add payload.fallbacks for per-job model fallback override (#26120) (#26304)
Co-authored-by: yinghaosang <yinghaosang@users.noreply.github.com>
2026-03-01 08:11:03 -06:00
BUGKillerKing
8c98cf05b2 i18n: add zh-CN for cron page and validation errors (#29315)
* i18n: add zh-CN for cron page and validation errors

* cron: treat unexpected delivery statuses as unknown

* test(cron): align validation tests with i18n keys

---------

Co-authored-by: 周鹤0668001310 <zhou.he3@xydigit.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 08:05:51 -06:00
Aviral
d0ca02e963 fix(cron): respect subagents.model in isolated cron sessions (#11474)
* fix(cron): respect subagents.model in isolated cron sessions

* fix(cron): enforce model allowlist for subagents.model

* Cron: fix isolated subagent model gate regressions

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:54:09 -06:00
wangchunyue
cb6f993b4c fix(cli): cron list Agent column shows agentId not model — add Model column (openclaw#26259) thanks @openperf
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:47:32 -06:00
Altay
98e30dc2a3 fix(cron): handle sessions list cron model override (openclaw#21279) thanks @altaywtf
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:32:20 -06:00
Sid
f107347608 fix(ui-cron): include configured model suggestions for scheduled jobs (openclaw#29709) thanks @Sid-Qin
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:31:47 -06:00
Pierre
5784963608 fix cron store backup churn (#19484) 2026-03-01 07:10:53 -06:00
Aleksandrs Tihenko
0cc46589ac Cron: drain pending writes before reading run log (#25416)
* Cron: drain pending writes before reading run log

* Retrigger CI
2026-03-01 07:04:04 -06:00
Sid
29a55948d6 fix(cron): guard list sorting against malformed legacy jobs (#28896)
* fix(cron): guard list sorting against malformed legacy jobs

Prevent list operations from crashing when old or corrupted cron entries are missing name/id fields by hardening sort comparators.

Closes #28862

* cron: format list sort guard test imports

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:01:36 -06:00
Glucksberg
645d963954 feat: expand ~ (tilde) to home directory in file tools (read/write/edit) (openclaw#29779) thanks @Glucksberg
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 07:00:52 -06:00
NIO
ea3955cd78 fix(cron): add retry policy for one-shot jobs on transient errors (#24355) (openclaw#24435) thanks @hugenshen
Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/cron/service.issue-regressions.test.ts src/config/config-misc.test.ts

Co-authored-by: hugenshen <16300669+hugenshen@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 06:58:03 -06:00
ToToKr
62a7683ce6 fix(cron): add audit logging for job create/update/remove (openclaw#25090) thanks @MoerAI
Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/gateway/server-cron.test.ts src/gateway/server-methods/server-methods.test.ts src/gateway/protocol/cron-validators.test.ts

Co-authored-by: MoerAI <26067127+MoerAI@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 06:55:48 -06:00
StingNing
5b49cc4129 fix(cron): notify user when cron job is auto-disabled after repeated errors (openclaw#29098) thanks @ningding97
Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/cron/service.runs-one-shot-main-job-disables-it.test.ts

Co-authored-by: ningding97 <17723822+ningding97@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 06:54:02 -06:00
Sid
504c1f3607 fix(cron): migrate legacy schedule cron fields on load (#28889)
Backfill legacy jobs that still use schedule.cron and jobId so upgraded instances keep firing existing cron schedules instead of failing silently.

Closes #28861
2026-03-01 06:53:39 -06:00
Sid
d509a81a12 fix(cron): treat transient tool error payloads as recoverable (openclaw#29527) thanks @Sid-Qin
Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts

Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 06:52:15 -06:00
Ayaan Zaidi
635c78a177 docs: add changelog entry for session_status levels (#30129) 2026-03-01 14:45:12 +05:30
YuzuruS
310344b6e4 fix: read thinking/verbose/reasoning levels from session entry in status
buildStatusMessage resolved thinkLevel, verboseLevel, and reasoningLevel
without falling back to sessionEntry, unlike elevatedLevel which already
had this fallback. When session_status tool calls buildStatusMessage
without passing resolvedThink/resolvedVerbose/resolvedReasoning, the
levels always fell back to agent defaults or "off", ignoring the
runtime-set session values.

Add sessionEntry fallback for thinkingLevel, verboseLevel, and
reasoningLevel, consistent with how elevatedLevel already works.

Closes #30126

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:42:34 +05:30
Shadow
54c46b7c8c temp disable stale workflow to help with ratelimits 2026-02-28 22:51:34 -06:00
Gustavo Madeira Santana
9257dfb5c0 fix(diffs): tighten rendering quality 2026-02-28 23:03:28 -05:00
Gustavo Madeira Santana
0f72000c96 fix(diffs): increase resolution scaling factor 2026-02-28 22:25:29 -05:00
Shadow
3685ccb536 chore: lock inactive closed issues 2026-02-28 20:48:02 -06:00
Gustavo Madeira Santana
c0ce125512 fix(gateway): shorten manual reinstall/restart delays
LaunchAgent plist hardcodes ThrottleInterval to 60 in src/daemon/launchd-plist.ts

That means every restart/install path that terminates the launchd-managed gateway gets delayed by launchd’s one-minute relaunch throttle. The CLI restart path in src/daemon/launchd.ts is doing the expected supervisor actions, but the plist policy makes those actions look hung.

In src/daemon/launchd-plist.ts:
- added LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS
- reduced the LaunchAgent ThrottleInterval from 60 to 1
2026-02-28 20:46:11 -05:00
Gustavo Madeira Santana
39e09273ca docs(diffs): update docs for diffs plugin 2026-02-28 20:40:30 -05:00
Gustavo Madeira Santana
0abf47cfd5 plugin(diffs): optimize rendering for image/view modes 2026-02-28 20:19:15 -05:00
Jarvis
fcb6859784 fix(memoryFlush): correct context token accounting for flush gating (#5343)
Merged via squash.

Prepared head SHA: afaa7bae3b
Co-authored-by: jarvis-medmatic <252428873+jarvis-medmatic@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-02-28 16:54:57 -08:00
Gustavo Madeira Santana
812a996b2f adding config layer 2026-02-28 19:20:07 -05:00
Gustavo Madeira Santana
1828fdee8b icons refined 2026-02-28 18:58:26 -05:00
Gustavo Madeira Santana
612ed5b3e1 diffs plugin 2026-02-28 18:38:00 -05:00
Vignesh Natarajan
fca0467082 TUI: guard SIGTERM shutdown against setRawMode EBADF 2026-02-28 14:56:01 -08:00
Vignesh Natarajan
2050fd7539 Cron: preserve session scope for main-target reminders 2026-02-28 14:53:19 -08:00
Yassine Amjad
61989091a4 fix(reply): fix duplicate block replies by unblocking coalesced payloads (#5080)
Merged via squash.

Prepared head SHA: 399e1259cb
Co-authored-by: yassine20011 <59234686+yassine20011@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-02-28 14:51:43 -08:00
Vignesh Natarajan
c58d2aa99e Sessions: fix sessions_list transcriptPath path resolution 2026-02-28 14:42:14 -08:00
Vignesh Natarajan
f57b4669e1 Memory: keep keyword hits when hybrid vector misses 2026-02-28 14:18:24 -08:00
Vignesh Natarajan
0929c233d8 TUI: sync /model status immediately 2026-02-28 14:02:56 -08:00
Vignesh Natarajan
a623c9c8d2 Onboarding: enforce custom model context minimum 2026-02-28 13:37:21 -08:00
Vignesh Natarajan
e90429794a Web UI: strip relevant-memories scaffolding 2026-02-28 13:20:50 -08:00
Vignesh Natarajan
ea4f5106ea chore(gateway): guard cron agent heartbeat type 2026-02-28 13:03:45 -08:00
Vignesh Natarajan
9868d5cd8b Gateway: allow control-ui session deletion 2026-02-28 13:01:10 -08:00
Vincent Koc
62179c861b Update server-cron.ts 2026-02-28 10:16:34 -08:00
Vincent Koc
6dae3c2ca6 Update models-config.providers.ts 2026-02-28 10:16:34 -08:00
Vincent Koc
8624f80649 Update models-config.providers.ollama.test.ts 2026-02-28 10:16:34 -08:00
Vincent Koc
b8863fc813 Update CHANGELOG.md 2026-02-28 10:16:34 -08:00
Kansodata Spa.
81d215afa7 fix(cron): narrow agentEntry type for heartbeat merge 2026-02-28 10:16:34 -08:00
Kansodata Spa.
247ff6ff9e fix(ollama): default explicit-model provider api to native ollama 2026-02-28 10:16:34 -08:00
Kansodata Spa.
0331fc5199 test(ollama): type explicit models input union in provider test 2026-02-28 10:16:34 -08:00
damaozi
78d49b4c8e fix: remove readonly type constraint in test 2026-02-28 10:16:34 -08:00
damaozi
deb9560a2b fix(agents): skip Ollama discovery when explicit models configured (#28762) 2026-02-28 10:16:34 -08:00
Vincent Koc
be8a5b9d64 chore(changelog): add missing entry for #28827 2026-02-28 09:47:06 -08:00
Vincent Koc
db25b26e33 chore(changelog): add missing entry for #25326 2026-02-28 09:47:06 -08:00
Vincent Koc
67a1584e82 chore(changelog): add missing entry for #26414 2026-02-28 09:47:06 -08:00
金炳
28c80689d4 fix(browser): resolve correct targetId in navigate response after renderer swap (#25326)
* fix(browser): resolve correct targetId in navigate response after renderer swap

When `navigateViaPlaywright` triggers a Chrome renderer-process swap
(e.g. navigating from chrome-extension:// to https://), the old
`tab.targetId` captured before navigation becomes stale. The `/navigate`
route previously returned this stale targetId in its response.

After navigation, re-resolve the current tab by matching against the
final URL via `profileCtx.listTabs()`. If the old target is already gone
but the new one is not yet visible (extension re-attach in progress),
retry after 800ms.

Follow-up to #19744 (67bac62c2) which fixed the extension-side stale
session cleanup.

* fix(browser): prefer non-stale targetId when multiple tabs share the same URL

When multiple tabs have the same URL after navigation, find() could pick
a pre-existing tab instead of the newly created one. Now only re-resolve
when the old target is gone (renderer swap detected), and prefer the tab
whose targetId differs from the old one.

* fix(browser): encapsulate targetId resolution logic after navigation

Introduced a new function `resolveTargetIdAfterNavigate` to handle the resolution of the correct targetId after a navigation event that may trigger a renderer swap. This refactor improves code clarity and reuses the logic for determining the current targetId, ensuring that the correct tab is identified even when multiple tabs share the same URL.

* refactor(tests): simplify listTabs initialization in agent snapshot tests

Updated the initialization of listTabs in the agent snapshot tests for better readability by removing unnecessary line breaks. This change enhances code clarity without altering the test logic.

* fix(ui): widen Set type to accept string tokens in external-link helper

* chore: retrigger CI (unrelated Windows flaky test)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-28 09:23:24 -08:00
Charlie Niño
26db298d3e fix: sed escaping and UID mismatch in Podman Quadlet setup (#26414)
* fix: sed escaping and UID mismatch in Podman Quadlet setup

Fix two bugs in the Podman/Quadlet installation path:

1. setup-podman.sh line 227: Remove `/` from sed escape character class.
   The sed substitution uses `|` as delimiter, so `/` doesn't need
   escaping. Including it causes paths like `/home/openclaw` to become
   `\/home\/openclaw`, which Podman rejects as invalid volume names.

2. openclaw.container.in: Add `User=%U:%G` after `UserNS=keep-id`.
   The Dockerfile sets `USER node` (UID 1000), but the `openclaw` system
   user created by setup-podman.sh may get a different UID (e.g., 1001).
   Without `User=%U:%G`, the container process runs as UID 1000 and
   cannot read config files owned by the openclaw user.

Closes #26400

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

* scripts: extract quadlet sed replacement escaping helper

* podman: document quadlet user mapping rationale

* scripts: correct sed replacement escaping for pipe delimiter

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-28 09:20:18 -08:00
Marcus Widing
8ae1987f2a fix(cron): pass heartbeat target=last for main-session cron jobs (#28508) (#28583)
* fix(cron): pass heartbeat target=last for main-session cron jobs

When a cron job with sessionTarget=main and wakeMode=now fires, it
triggers a heartbeat via runHeartbeatOnce. Since e2362d35 changed the
default heartbeat target from "last" to "none", these cron-triggered
heartbeats silently discard their responses instead of delivering them
to the last active channel (e.g. Telegram).

Fix: pass heartbeat: { target: "last" } from the cron timer to
runHeartbeatOnce for main-session jobs, and wire the override through
the gateway cron service builder. This restores delivery for
sessionTarget=main cron jobs without reverting the intentional default
change for regular heartbeats.

Regression introduced in: e2362d35 (2026-02-25)

Fixes #28508

* Cron: align server-cron wake routing expectations for main-target jobs

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 11:14:24 -06:00
lailoo
d7d3416b1d fix(cron): disable messaging tool when delivery.mode is none (#21808) (#21896) 2026-02-28 11:12:17 -06:00
Mitsuyuki Osabe
e1df1c60b8 fix: clear delivery routing state when creating isolated cron sessions (#27778)
* fix: clear delivery routing state when creating isolated cron sessions

When `resolveCronSession()` creates a new session (forceNew / isolated),
the `...entry` spread preserves `lastThreadId`, `lastTo`, `lastChannel`,
and `lastAccountId` from the prior session. This causes announce-mode
cron deliveries to post as thread replies instead of channel top-level
messages when `delivery.to` matches the channel of a prior conversation.

Clear delivery routing metadata on new session creation so isolated
cron sessions start with a clean delivery state.

Closes #27751

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)

* fix: also clear deliveryContext to prevent lastThreadId repopulation

normalizeSessionEntryDelivery (called on store writes) repopulates
lastThreadId from deliveryContext.threadId. Clearing only the last*
fields is insufficient — deliveryContext must also be cleared when
creating a new isolated session.

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)
2026-02-28 11:09:12 -06:00
Sid
daa418895e fix(cron): avoid marking queued announce paths as delivered (#29716)
Cron announce flow treated queued/steered outcomes as delivered even when no direct outbound send was confirmed, which could report false-positive delivery state. This change keeps cron delivery strict: only direct-path announce results count as delivered.

Closes #29660
2026-02-28 11:09:09 -06:00
Dennis Goldfinger
3096837238 fix(cron): enable completion direct send for text-only announce delivery (#29151) 2026-02-28 11:09:07 -06:00
Sid
fe9a7c4082 fix(cron): force main-target system events onto main session (#28898)
Ignore persisted sessionKey overrides for sessionTarget=main jobs so cron system events consistently route to the agent main session after upgrades.

Closes #28770
2026-02-28 11:08:53 -06:00
Anandesh Sharma
2851926314 fix(cron): condition requireExplicitMessageTarget on resolved delivery (#28017)
When a cron job's delivery target resolution fails (resolvedDelivery.ok
is false), the agent was still started with requireExplicitMessageTarget:
true. This caused "Action send requires a target" errors because the
agent's message tool demanded a target that was never resolved.

Condition the flag on both deliveryRequested AND resolvedDelivery.ok so
the agent can still use messaging tools freely when no valid delivery
target exists.

Fixes #27898

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:08:37 -06:00
Marvin
5e2ef0e883 feat(cron): add --account flag for multi-account delivery routing (#26284)
* feat(cron): add --account flag for multi-account delivery routing

Add support for explicit delivery account routing in cron jobs across CLI, normalization, delivery planning, and isolated delivery target resolution.

Highlights:
- Add --account <id> to cron add and cron edit
- Add optional delivery.accountId to cron types and delivery plan
- Normalize and trim delivery.accountId in cron create/update normalization
- Prefer explicit accountId over session lastAccountId and bindings fallback
- Thread accountId through isolated cron run delivery resolution
- Preserve cron edit --best-effort-deliver/--no-best-effort-deliver behavior by keeping implicit announce mode
- Expand tests for account passthrough/merge/precedence and CLI account flows

* cron: resolve rebase duplicate accountId fields

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 10:57:49 -06:00
Pierre
e1c8094ad0 fix: schedule nextWakeAtMs for isolated sessionTarget cron jobs (#19541)
* fix(cron): repair isolated next wake scheduling

* cron: harden isolated next-wake timestamp guards

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 10:48:31 -06:00
Ayaan Zaidi
139271ad5a fix: sandbox browser docker no-sandbox rollout (#29879) (thanks @Lukavyi) 2026-02-28 21:43:56 +05:30
Tak Hoffman
7ae683194f GitHub: add regression bug issue template and routing (openclaw#29864) thanks @Takhoffman
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 10:02:55 -06:00
刘苇
5209c48923 feat(feishu): add chat info/member tool (openclaw#14674)
* feat(feishu): add chat members/info tool support

* Feishu: harden chat tool schema and coverage

---------

Co-authored-by: Nereo <nereo@Nereos-Mac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 10:00:31 -06:00
Elarwei
0740fb83d7 feat(feishu): add markdown tables, positional insert, color_text, and table ops (#29411)
* feat(feishu): add markdown tables, insert, color_text, table ops, and image fixes

Extends feishu_doc on top of #20304 with capabilities that are not yet covered:

Markdown → native table rendering:
- write/append now use the Descendant API instead of Children API,
  enabling GFM markdown tables (block_type 31/32) to render as native
  Feishu tables automatically
- Adaptive column widths calculated from cell content (CJK chars 2x weight)
- Batch insertion for large documents (>1000 blocks, docx-batch-insert.ts)

New actions:
- insert: positional markdown insertion after a given block_id
- color_text: apply color/bold to a text block via [red]...[/red] markup
- insert_table_row / insert_table_column: add rows or columns to a table
- delete_table_rows / delete_table_columns: remove rows or columns
- merge_table_cells: merge a rectangular cell range

Image upload fixes (affects write, append, and upload_image):
- upload_image now accepts data URI and plain base64 in addition to
  url/file_path, covering DALL-E b64_json, canvas screenshots, etc.
- Fix: pass Buffer directly to drive.media.uploadAll instead of
  Readable.from(), which caused Content-Length mismatch for large images
- Fix: same Readable bug fixed in upload_file
- Fix: pass drive_route_token via extra field for correct multi-datacenter
  routing (per API docs: required when parent_node is a document block ID)

* fix(feishu): add documentBlockDescendant mock to docx.test.ts

write/append now use the Descendant API (documentBlockDescendant.create)
instead of Children API. The existing test mock was missing this SDK
method, causing processImages to never be reached and fetchRemoteMedia
to go uncalled.

Added blockDescendantCreateMock returning an image block so the
'skips image upload when markdown image URL is blocked' test flows
through processImages as expected.

* fix(feishu): address bot review feedback

- resolveUploadInput: remove length < 1024 guard on file path detection.
  Prefix patterns (isAbsolute / ~ / ./ / ../) already correctly distinguish
  file paths from base64 strings at any length. The old guard caused file
  paths ≥1024 chars to fall through to the base64 branch incorrectly.

- parseColorMarkup: add comment clarifying that mismatched closing tags
  (e.g. [red]text[/green]) are intentional — opening tag style is applied,
  closing tag is consumed regardless of name.

* fix(feishu): address second-round codex bot review feedback

P1 - Reject single oversized subtrees in batch insert (docx-batch-insert.ts):
  A first-level block whose descendant count exceeds BATCH_SIZE (1000) cannot
  be split atomically (e.g. a very large table). Previously such a block was
  silently added to the current batch and sent as an oversized request,
  violating the API limit. Now throws a descriptive error so callers know to
  reduce the content size.

P2 - Preserve unmatched brackets in color markup parser (docx-color-text.ts):
  Text like 'Revenue [Q1] up' contains a bracket pair with no matching '[/...]'
  closer. The original regex dropped the '[' character in this case, silently
  corrupting the text. Fixed by appending '|\[' to the plain-text alternative
  so any '[' that does not open a complete tag is captured as literal text.

* fix(feishu): address third-round codex bot review feedback

P2 - Throw ENOENT for non-existing absolute image paths (docx.ts):
  Previously a non-existing absolute path like /tmp/missing.png fell
  through to Buffer.from(..., 'base64') and uploaded garbage bytes.
  Now throws a descriptive ENOENT error and hints at data URI format
  for callers intending to pass JPEG binary data (which starts with /9j/).

P2 - Fail clearly when insert anchor block is not found (docx.ts):
  insertDoc previously set insertIndex to -1 (append) when after_block_id
  was absent from the parent's child list, silently inserting at the wrong
  position. Two fixes:
  1. Paginate through all children (documentBlockChildren.get returns up to
     200 per page) before searching for the anchor.
  2. Throw a descriptive error if after_block_id is still not found after
     full pagination, instead of silently falling back to append.

* fix(feishu): address fourth-round codex bot review feedback

- Enforce mutual exclusivity across all three upload sources (url, file_path,
  image): throw immediately when more than one is provided, instead of silently
  preferring the image branch and ignoring the others.
- Validate plain base64 payloads before decoding: reject strings that contain
  characters outside the standard base64 alphabet ([A-Za-z0-9+/=]) so that
  malformed inputs fail fast with a clear error rather than decoding to garbage
  bytes and producing an opaque Feishu API failure downstream.
  Also throw if the decoded buffer is empty.

* fix(feishu): address fifth-round codex bot review feedback

- parseColorMarkup: restrict opening tag regex to known colour/style names
  (bg:*, bold, red, orange, yellow, green, blue, purple, grey/gray) so that
  ordinary bracket tokens like [Q1] can no longer consume a subsequent real
  closing tag ([/red]) and corrupt the surrounding styled spans.  Unknown tags
  now fall through to the plain-text alternatives and are emitted literally.
- resolveUploadInput: estimate decoded byte count from base64 input length
  (ceil(len * 3 / 4)) BEFORE allocating the full Buffer, preventing oversized
  payloads from spiking memory before the maxBytes limit is enforced.  Applies
  to both the data-URI branch and the plain-base64 branch.

* fix(feishu): address sixth-round codex bot review feedback

- docx-table-ops: apply MIN/MAX_COLUMN_WIDTH clamping in the empty-table
  branch so tables with 15+ columns don't produce sub-50 widths that Feishu
  rejects as invalid column_width values.
- docx.ts (data URI branch): validate the ';base64' marker before decoding
  so plain/URL-encoded data URIs are rejected with a clear error; also validate
  the payload against the base64 alphabet (same guard already applied in the
  plain-base64 branch) so malformed inputs fail fast rather than producing
  opaque downstream Feishu errors.

* Feishu: align docx descendant insertion tests and changelog

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 09:58:56 -06:00
Chuan Liu
4ad49de89d feat(feishu): add parent/root inbound context for quote support (openclaw#18529)
* feat(feishu): add parentId and rootId to inbound context

Add ParentMessageId and RootMessageId fields to Feishu inbound message context,
enabling agents to:
- Identify quoted/replied messages
- Fetch original message content via Feishu API
- Build proper message thread context

The parent_id and root_id fields already exist in FeishuMessageContext but were
not being passed to the agent's inbound context.

Fixes: Allows proper handling of quoted card messages and message thread reconstruction.

* feat(feishu): parse interactive card content in quoted messages

Add support for extracting readable text from interactive card messages
when fetching quoted/replied message content.

Previously, only text messages were parsed. Now interactive cards
(with div and markdown elements) are also converted to readable text.

* 更新 bot.ts

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

* fix(types): add RootMessageId to MsgContext type definition

* style: fix formatting in bot.ts

* ci: trigger rebuild

* ci: retry flaky tests

* Feishu: add reply-context and interactive-quote regressions

---------

Co-authored-by: qiangu <qiangu@qq.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: 牛牛 <niuniu@openclaw.ai>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-28 09:55:50 -06:00
Ayaan Zaidi
9b39490d6a fix: land android onboarding and voice reliability updates (#29796) 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
1d7b76a90e fix(android-voice): rotate playback token per assistant reply 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
addc619087 fix(android-voice): retry talk config after transient failures 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
930e94024a fix(android-voice): cancel in-flight speech when speaker muted 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
727ae469cf perf(android): reduce mic conversation update churn 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
3daed77ba9 fix(android): unify voice speaker gating and config refresh 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
72e135083a feat(android-voice): add speaker toggle in voice tab 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
fb92a91ef7 fix(android): speak final voice replies in mic capture flow 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
fcf3e5b0a0 fix(android): expose talk-mode assistant speech entrypoint 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
eea081c709 fix(android): update onboarding pairing commands 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
548a28a13f fix(android): request onboarding permissions per toggle 2026-02-28 20:05:59 +05:30
Ayaan Zaidi
f0c86039c7 fix: clarify outside-workspace fs-safe errors (#29715) (thanks @YuzuruS) 2026-02-28 18:08:10 +05:30
Ayaan Zaidi
44220ef24a test: add outside-workspace error mapping coverage 2026-02-28 18:08:10 +05:30
YuzuruS
d6552998e9 fix: handle outside-workspace error in media store
Address Greptile review: add explicit "outside-workspace" case to
toSaveMediaSourceError so it returns "Media path is outside workspace
root" instead of the generic "Media path is not safe to read".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:08:10 +05:30
YuzuruS
f5c2be1910 fix: distinguish outside-workspace errors from not-found in fs-safe
When editing a file outside the workspace root, SafeOpenError previously
used the "invalid-path" code with the message "path escapes root". This
was indistinguishable from other invalid-path errors (hardlinks, symlinks,
non-files) and consumers often fell back to a generic "not found" message,
which was misleading.

Add a new "outside-workspace" error code with the message "file is outside
workspace root" so consumers can surface a clear, accurate error message.

- fs-safe.ts: add "outside-workspace" to SafeOpenErrorCode, use it for
  all path-escapes-root checks in openFileWithinRoot/writeFileWithinRoot
- pi-tools.read.ts: map "outside-workspace" to EACCES instead of rethrowing
- browser/paths.ts: return specific "File is outside {scopeLabel}" message
- media/server.ts: return 400 with descriptive message for outside-workspace
- fs-safe.test.ts: update traversal test expectations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:08:10 +05:30
Ayaan Zaidi
150c2093fa test: make feishu proxy precedence assertion cross-platform 2026-02-28 17:14:45 +05:30
Ayaan Zaidi
89e158fc96 fix: harden azure custom-provider verification coverage (#29421) (thanks @kunalk16) 2026-02-28 15:58:20 +05:30
Kunal Karmakar
720e1479b8 Remove temperature 2026-02-28 15:58:20 +05:30
Kunal Karmakar
2258e736b0 Reduce default max tokens 2026-02-28 15:58:20 +05:30
Kunal Karmakar
2fe5620763 Fix linting issue 2026-02-28 15:58:20 +05:30
Kunal Karmakar
4ed12c18a0 Conditional azure openai endpoint usage 2026-02-28 15:58:20 +05:30
Kunal Karmakar
06a3175cd1 Fix linting issue 2026-02-28 15:58:20 +05:30
Kunal Karmakar
955768d132 Fix default max tokens 2026-02-28 15:58:20 +05:30
Kunal Karmakar
978d9ae199 Fix azure openai endpoint validation 2026-02-28 15:58:20 +05:30
Tony Dehnke
f1bf558685 fix(doctor): detect groupPolicy=allowlist with empty groupAllowFrom (#28477)
* fix(doctor): detect groupPolicy=allowlist with empty groupAllowFrom

The existing `detectEmptyAllowlistPolicy` check only covers
`dmPolicy="allowlist"` with empty `allowFrom`. After the .26 security
hardening (`resolveDmGroupAccessDecision` fails closed on empty
allowlists), `groupPolicy="allowlist"` without `groupAllowFrom` or
`allowFrom` silently drops all group/channel messages with only a
verbose-level log.

Add a parallel check: when `groupPolicy` is `"allowlist"` and neither
`groupAllowFrom` nor `allowFrom` has entries, surface a doctor warning
with remediation steps.

Closes #27552

* fix: align empty-array semantics with runtime resolveGroupAllowFromSources

The runtime treats groupAllowFrom: [] as unset and falls back to
allowFrom, but the doctor check used ?? which treats [] as authoritative.
This caused a false warning when groupAllowFrom was explicitly empty but
allowFrom had entries.

Match runtime behavior: treat empty groupAllowFrom arrays as unset
before falling back to allowFrom.

* fix: scope group allowlist check to sender-based channels only

* fix: align doctor group allowlist semantics (#28477) (thanks @tonydehnke)

---------

Co-authored-by: mukhtharcm <mukhtharcm@gmail.com>
2026-02-28 14:45:10 +05:30
Vincent Koc
5d51e99537 Changelog: add missing entries for #29279 and #29299 (#29579) 2026-02-28 00:03:44 -08:00
Vincent Koc
d123ade0cb fix(gateway): allow required Google Fonts origins in Control UI CSP (#29279)
* Gateway: allow Google Fonts stylesheet and font CDN in Control UI CSP

* Tests: assert Control UI CSP allows required Google Fonts origins

* Gateway: fix CSP comment for Google Fonts allowlist intent

* Tests: split dedicated Google Fonts CSP assertion
2026-02-27 23:58:51 -08:00
Vincent Koc
f810932859 Feishu: fix locale-wrapper post parser test (#29576) 2026-02-27 23:57:27 -08:00
Vincent Koc
b297bae027 fix(cli): allow Ollama apiKey config set without predeclared provider (#29299)
* CLI: seed Ollama provider on apiKey set

* Tests: cover Ollama apiKey config set path
2026-02-27 23:35:57 -08:00
Vincent Koc
7968c0f514 Changelog: add model fallback reasoning fix (#29285) 2026-02-27 23:30:27 -08:00
Ayaan Zaidi
3f056a7294 fix(android): block onboarding advance until special setup is complete 2026-02-28 12:29:52 +05:30
Ayaan Zaidi
cd61edb0f3 fix(android): add missing capability setup surfaces 2026-02-28 12:29:52 +05:30
Ayaan Zaidi
3899c89805 docs(changelog): add #29440 android notification wake notes 2026-02-28 11:18:01 +05:30
Ayaan Zaidi
6a16e7bb31 fix(gateway): skip heartbeat wake on deduped notifications 2026-02-28 11:18:01 +05:30
Ayaan Zaidi
a8bcad3db1 fix(gateway): canonicalize notification wake session 2026-02-28 11:18:01 +05:30
Ayaan Zaidi
f1bb26642c fix(gateway): scope notification wakeups to session 2026-02-28 11:18:01 +05:30
Ayaan Zaidi
9d3ccf4754 feat(gateway): enable Android notify + notification events 2026-02-28 11:18:01 +05:30
smthfoxy
5350f5b035 fix(tts): use opus format and enable voice bubbles for feishu and whatsapp (#27366)
* fix(tts): use opus format and enable voice bubbles for feishu and whatsapp

Previously only Telegram received opus output and had `shouldVoice=true`.
Feishu and WhatsApp also support voice-bubble playback and require opus audio,
but were falling back to mp3 with `audioAsVoice=false`.

- Extract VOICE_BUBBLE_CHANNELS set (telegram, feishu, whatsapp)
- resolveOutputFormat: return TELEGRAM_OUTPUT (opus) for all voice-bubble channels
- shouldVoice: enable for all voice-bubble channels, not just telegram
- Update test to cover feishu and whatsapp cases

* Changelog: add TTS voice-bubble channel coverage note

---------

Co-authored-by: Ning Hu <ninghu@Nings-MacBook-Pro.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:41:22 -06:00
laopuhuluwa
53a2e72fcb feat(feishu): extract embedded video/media from post (rich text) messages (#21786)
* feat(feishu): extract embedded video/media from post (rich text) messages

Previously, parsePostContent() only extracted embedded images (img tags)
from rich text posts, ignoring embedded video/audio (media tags). Users
sending post messages with embedded videos would not have the media
downloaded or forwarded to the agent.

Changes:
- Extend parsePostContent() to also collect media tags with file_key
- Return new mediaKeys array alongside existing imageKeys
- Update resolveFeishuMediaList() to download embedded media files
  from post messages using the messageResource API
- Add appropriate logging for embedded media discovery and download

* Feishu: keep embedded post media payloads type-safe

* Feishu: format post parser after media tag extraction

---------

Co-authored-by: laopuhuluwa <laopuhuluwa@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:39:24 -06:00
Jealous
b0a8909a73 fix(feishu): fix group policy enforcement gaps (#25439)
- Respect groupConfig.enabled flag (was parsed but never enforced)
- Fix misleading log: group allowlist rejection now logs group ID and
  policy instead of sender open_id
2026-02-27 23:39:21 -06:00
WilsonLiu95
8818464f5f feat(feishu): render post rich text as markdown (openclaw#12755)
* feat(feishu): parse post rich text as markdown

* chore: rerun ci

* Feishu: resolve post parser rebase conflicts and gate fixes

---------

Co-authored-by: Wilson Liu <wilson.liu@example.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:33:20 -06:00
Clawborn
49cf2bceb6 fix(feishu): handle card.action.trigger callbacks (openclaw#17863)
Co-authored-by: Kai <clawborn@users.noreply.github.com>
2026-02-27 23:24:11 -06:00
Tak Hoffman
60bf56517f fix(feishu): honor wildcard group config for reply policy (#29456)
## Summary
- honor Feishu wildcard group policy fallback via `channels.feishu.groups["*"]` when no explicit group entry matches
- keep exact and case-insensitive explicit group matches higher precedence than wildcard fallback
- add changelog credit and TypeScript-safe test assertions

## Verification
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Wayne Pika <262095977+WaynePika@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:22:38 -06:00
songlei
8a2273e210 feat(feishu): support optional header in streaming cards (openclaw#22826)
Add an optional `header` parameter to `FeishuStreamingSession.start()`
so that streaming cards can display a colored title bar, matching the
appearance of non-streaming interactive cards.

The Card Kit API already supports `header` alongside `streaming_mode`,
but the current implementation omits it, producing headerless cards.

This change is fully backward-compatible: when `header` is not provided,
behavior is identical to before.

Closes #13267 (partial)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:21:22 -06:00
Colin Lee
0a23739c37 fix(feishu): pass proxy agent to WSClient for proxy environments (#26397)
* fix(feishu): pass proxy agent to WSClient for environments behind HTTPS proxy

The Lark SDK WSClient uses the `ws` library which does not automatically
respect https_proxy/HTTP_PROXY environment variables. This causes WebSocket
connection failures in proxy environments (e.g. WSL2 with a local proxy).

Detect proxy env vars and pass an HttpsProxyAgent to WSClient via the
existing `agent` constructor option.

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

* fix(feishu): add generic type parameter to HttpsProxyAgent return type

Fix TS2314: `HttpsProxyAgent<Uri>` requires a type argument.

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

* fix(feishu): wire ws proxy dependency and coverage

* chore(lockfile): resolve axios peer lock entry after rebase

---------

Co-authored-by: lirui <lirui@fxiaoke.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:15:11 -06:00
Menglin Li
4dc55ea88d fix(feishu): chunk large documents for write/append to avoid API 400 errors (#14402)
* fix(feishu): chunk large documents for write/append to avoid API 400 errors

The Feishu API limits documentBlockChildren.create to 50 blocks per
request and document.convert has content size limits for large markdown.

Previously, writeDoc and appendDoc would send the entire content in a
single API call, causing HTTP 400 errors for long documents.

This commit adds:
- splitMarkdownByHeadings(): splits markdown at # or ## headings
- chunkedConvertMarkdown(): converts each chunk independently
- chunkedInsertBlocks(): batches blocks into groups of ≤50

Both writeDoc and appendDoc now use the chunked helpers while
preserving backward compatibility for small documents. Image
processing correctly receives all inserted blocks across batches.

* fix(feishu): skip heading detection inside fenced code blocks

Addresses review feedback: splitMarkdownByHeadings() now tracks
fenced code blocks (``` or ~~~) and skips heading-based splitting
when inside one, preventing corruption of code block content.

* Feishu/Docx: add convert fallback chunking + tests

---------

Co-authored-by: lml2468 <lml2468@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:11:12 -06:00
BigUncle
27882dc73e feat(feishu): add quota optimization flags (openclaw#10513) thanks @BigUncle
Verified:
- pnpm build
- pnpm check
- pnpm vitest run --config vitest.extensions.config.ts extensions/feishu/src/config-schema.test.ts extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/bot.test.ts

Co-authored-by: BigUncle <9360607+BigUncle@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 23:05:54 -06:00
Rohin
e0b1b48be3 feishu: fall back to user_id for inbound sender identity (openclaw#26703) thanks @NewdlDewdl
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: NewdlDewdl <230946873+NewdlDewdl@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:59:42 -06:00
Ayaan Zaidi
f29c642c13 fix(release): enforce lane floor for calver appcast entries 2026-02-28 10:28:53 +05:30
Clawborn
10f1be1072 fix(feishu): replace console.log with runtime log for typing indicator errors (openclaw#18841) thanks @Clawborn
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Clawborn <135319479+Clawborn@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:57:16 -06:00
Shadow
a5b1e86535 chore: add fallback GitHub App token 2026-02-27 22:49:58 -06:00
YAXUAN
8beb048a84 test(feishu): add regression for audio download resource type=file (openclaw#16311) thanks @Yaxuan42
Verified:
- pnpm build
- pnpm check
- pnpm vitest run --config vitest.extensions.config.ts extensions/feishu/src/bot.test.ts extensions/feishu/src/media.test.ts

Co-authored-by: Yaxuan42 <184813557+Yaxuan42@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:49:05 -06:00
青雲
b28344eacc fix(feishu): insert document blocks sequentially to preserve order (#26022) (openclaw#26172) thanks @echoVic
Verified:
- pnpm build
- pnpm check
- pnpm vitest run --config vitest.extensions.config.ts extensions/feishu/src/docx.test.ts

Co-authored-by: echoVic <16428813+echoVic@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:48:14 -06:00
Ayaan Zaidi
83698bf13e fix(macos): derive canonical APP_BUILD after deps install 2026-02-28 10:04:25 +05:30
Ayaan Zaidi
af9edc98e4 fix(release): unify sparkle build policy and defaults 2026-02-28 10:04:25 +05:30
Logan Pritchett
3e55cc5811 appcast: fix sparkle version for 2026.2.26 2026-02-28 10:04:25 +05:30
Logan Pritchett
84adedd1cb macos: treat empty APP_BUILD as fallback 2026-02-28 10:04:25 +05:30
Logan Pritchett
0332dce203 macos: parse calver month/day as decimal for Sparkle build 2026-02-28 10:04:25 +05:30
Logan Pritchett
e4ee585b73 release-check: align appcast floor with Sparkle build lanes 2026-02-28 10:04:25 +05:30
Logan Pritchett
08fd579412 macos: make derived Sparkle build unique for same-day releases 2026-02-28 10:04:25 +05:30
Logan Pritchett
266f10d47d docs: clarify Sparkle build version policy 2026-02-28 10:04:25 +05:30
Logan Pritchett
3be12b9fc4 release-check: validate appcast sparkle version floor 2026-02-28 10:04:25 +05:30
Logan Pritchett
7237b4666b macos: make default Sparkle build version monotonic 2026-02-28 10:04:25 +05:30
longfros
6e645300a8 docs(feishu): clarify oc_ group allowlist vs ou_ command allowFrom for /reset (#26835)
* docs(feishu): clarify oc_* group allowlist vs ou_* command allowFrom

* docs(feishu): avoid direct edits to generated zh-CN docs

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:30:05 -06:00
Cassius0924
4f8a54eeaa docs: add cardkit permissions to Feishu channel setup (#9410)
- Add cardkit:card:read and cardkit:card:write to tenant scopes
- Format user scopes array for better readability
- Update both English and Chinese documentation

Co-authored-by: hezhizhou.606 <hezhizhou.606@bytedance.com>
2026-02-27 22:29:54 -06:00
傅洋
e4cb6a88be fix(feishu): handle message_type "media" for video downloads (openclaw#25502) thanks @4ier
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: 4ier <5648066+4ier@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:28:37 -06:00
Yihao
d9230b13a4 feat(feishu): skip reply-to in DM conversations (#13211)
In DM (p2p) chats, use message.create instead of message.reply
so that bot responses don't show a 'Reply to' quote. Group chats
retain the reply-to behavior for context clarity.

The typing indicator (emoji reaction on the user's message) is
preserved in DMs — only the reply reference in sent messages is
removed.

Changes:
- Add skipReplyToInMessages param to createFeishuReplyDispatcher
- In bot.ts, set skipReplyToInMessages: !isGroup for both dispatch sites
- In reply-dispatcher.ts, use sendReplyToMessageId (undefined for DMs)
  for message sending while keeping replyToMessageId for typing indicator
2026-02-27 22:24:42 -06:00
neverland
6a8d83b6dd fix(feishu): Remove incorrect oc_ prefix assumption in resolveFeishuSession (#10407)
* fix(feishu): remove incorrect oc_ prefix assumption in resolveFeishuSession

- Feishu oc_ is a generic chat_id that can represent both groups and DMs
- Must use chat_mode field from API to distinguish, not ID prefix
- Only ou_/on_ prefixes reliably indicate user IDs (always DM)
- Fixes session misrouting for DMs with oc_ chat IDs

This bug caused DM messages with oc_ chat_ids to be incorrectly
created as group sessions, breaking session isolation and routing.

* docs: update Feishu ID format comment to reflect oc_ ambiguity

The previous comment incorrectly stated oc_ is always a group chat.
This update clarifies that oc_ chat_ids can be either groups or DMs,
and explicit prefixes (dm:/group:) should be used to distinguish.

* feishu: add regression coverage for oc session routing

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 22:16:20 -06:00
Ayaan Zaidi
079bc24613 fix: add changelog entry for android capability parity (#29398) 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
1bc9da8f9e fix(android): stabilize motion sampling and gate pedometer command 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
18e7938dfd refactor(android): remove unreachable motion classify branch 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
943dce37be feat(android): wire new device capabilities into runtime 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
b9e474deb4 feat(android): add motion activity and pedometer handlers 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
f75385981a feat(android): add calendar capability handlers 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
81ebe7de46 feat(android): add contacts capability handlers 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
c8ad229776 feat(android): add photos latest handler 2026-02-28 09:27:54 +05:30
Ayaan Zaidi
f637cbd246 feat(android): add system notification handler 2026-02-28 09:27:54 +05:30
Haitian
107be4e909 feat(feishu): add global groupSenderAllowFrom for sender-level group access control (openclaw#29174) thanks @1MoreBuild
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: 1MoreBuild <11406106+1MoreBuild@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 21:49:47 -06:00
Tak Hoffman
aef5355102 fix(feishu): add reactionNotifications mode gating (openclaw#29388) thanks @Takhoffman
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 21:47:12 -06:00
TIHU
0e4c24ebe2 fix(feishu): auto-convert local image path text to image message in outbound (openclaw#29264) thanks @paceyw
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: paceyw <44923937+paceyw@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 21:29:11 -06:00
Ayaan Zaidi
3f06693e7d refactor(android): share node capability and command manifest 2026-02-28 08:46:50 +05:30
tsu-builds
f53ef73a2b feat(feishu): add support for merge_forward message parsing (openclaw#28707) thanks @tsu-builds
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: tsu-builds <264409075+tsu-builds@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 20:57:18 -06:00
Lin Z
8241145ada feat(feishu): add reaction event support (created/deleted) (openclaw#16716) thanks @schumilin
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: schumilin <2003498+schumilin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 20:54:24 -06:00
Ayaan Zaidi
afa7ac1f68 docs: update changelog for telegram outbound chunking (#29342) (thanks @obviyus) 2026-02-28 08:13:59 +05:30
Ayaan Zaidi
2bef2910f1 fix: preserve whitespace in telegram html retry chunking 2026-02-28 08:13:59 +05:30
Ayaan Zaidi
69c39368ec fix: enforce telegram shared outbound chunking 2026-02-28 08:13:59 +05:30
Sid
4221b5f809 fix: pass rootId to streaming card in Feishu topic groups (openclaw#28346) thanks @Sid-Qin
Verified:
- pnpm check
- pnpm test extensions/feishu/src/reply-dispatcher.test.ts

Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 20:20:53 -06:00
Shawn
da00ead652 fix(feishu): parse code blocks and share_chat messages (openclaw#28591) thanks @kevinWangSheng
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: kevinWangSheng <118158941+kevinWangSheng@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 20:15:48 -06:00
kcinzgg
89669a33bd feat(feishu): add replyInThread configuration for message replies (openclaw#27325) thanks @kcinzgg
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: kcinzgg <13964709+kcinzgg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 19:53:02 -06:00
Vincent Koc
50aa6a43ed fix(model): preserve reasoning in provider fallback resolution (#29285)
* fix(model): preserve reasoning in provider fallback resolution

* test(model): cover fallback reasoning propagation
2026-02-27 17:38:22 -08:00
Vincent Koc
8090cb4c5e docs: missing changelog itesm (#29281)
* Changelog: add LanceDB custom baseUrl + dimensions entry (#17874)

* Changelog: add Ollama autodiscovery hardening entry (#29201)

* Changelog: add Ollama context-window unification entry (#29205)

* Changelog: add compaction audit injection removal entry (#28507)

* Changelog: add browser url alias entry (#29260)

* Changelog: add codex weekly usage label entry (#26267)
2026-02-27 17:31:09 -08:00
拐爷&&老拐瘦
36d69d05e2 feat(feishu): support sender/topic-scoped group session routing (openclaw#17798) thanks @yfge
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: yfge <1186273+yfge@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 19:26:36 -06:00
Vincent Koc
ed51796d97 fix(browser): accept url alias for open and navigate (#29260)
* fix(browser): expose url alias in tool schema

* fix(browser): accept url alias for open and navigate

* test(browser): cover url alias for open and navigate
2026-02-27 17:25:59 -08:00
Sid
e16d051d9f fix: label Codex weekly usage window as "Week" instead of "Day" (#26267)
The secondary window label logic treated any window >= 24h as "Day",
but Codex plans can have a weekly (604800s / 168h) quota window.
The reset timer showed "resets 2d 4h" while the label said "Day",
which was confusing.

Now windows >= 168h are labeled "Week", >= 24h remain "Day", and
shorter windows show the hour count.

Closes #25812

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 17:23:01 -08:00
Vincent Koc
f16ecd1dac fix(ollama): unify context window handling across discovery, merge, and OpenAI-compat transport (#29205)
* fix(ollama): inject num_ctx for OpenAI-compatible transport

* fix(ollama): discover per-model context and preserve higher limits

* fix(agents): prefer matching provider model for fallback limits

* fix(types): require numeric token limits in provider model merge

* fix(types): accept unknown payload in ollama num_ctx wrapper

* fix(types): simplify ollama settled-result extraction

* config(models): add provider flag for Ollama OpenAI num_ctx injection

* config(schema): allow provider num_ctx injection flag

* config(labels): label provider num_ctx injection flag

* config(help): document provider num_ctx injection flag

* agents(ollama): gate OpenAI num_ctx injection with provider config

* tests(ollama): cover provider num_ctx injection flag behavior

* docs(config): list provider num_ctx injection option

* docs(ollama): document OpenAI num_ctx injection toggle

* docs(config): clarify merge token-limit precedence

* config(help): note merge uses higher model token limits

* fix(ollama): cap /api/show discovery concurrency

* fix(ollama): restrict num_ctx injection to OpenAI compat

* tests(ollama): cover ipv6 and compat num_ctx gating

* fix(ollama): detect remote compat endpoints for ollama-labeled providers

* fix(ollama): cap per-model /api/show lookups to bound discovery load
2026-02-27 17:20:47 -08:00
fuller-stack-dev
70a4f25ab1 fix(security): remove post-compaction audit injection message (#28507)
* fix: remove post-compaction audit injection (Layer 3)

Remove the post-compaction read audit that injects fake system messages
into conversations after context compaction. This audit:

- Hardcodes WORKFLOW_AUTO.md (a file that doesn't exist in standard
  workspaces) as a required read after every compaction
- Leaks raw regex syntax (memory\/\d{4}-\d{2}-\d{2}\.md) in
  user-facing warning messages
- Injects messages via enqueueSystemEvent that appear as user-role
  messages, tricking agents into reading attacker-controlled files
- Creates a persistent prompt injection vector (see #27697)

Layer 1 (compaction summary) and Layer 2 (workspace context refresh
from AGENTS.md via post-compaction-context.ts) remain intact and are
sufficient for post-compaction context recovery.

Deleted files:
- src/auto-reply/reply/post-compaction-audit.ts
- src/auto-reply/reply/post-compaction-audit.test.ts

Modified files:
- src/auto-reply/reply/agent-runner.ts (removed imports, audit map,
  flag setting, and Layer 3 audit block)

Fixes #27697, fixes #26851, fixes #20484, fixes #22339, fixes #25600
Relates to #26461

* fix: resolve lint failures from post-compaction audit removal

* Tests: add regression for removed post-compaction audit warnings

---------

Co-authored-by: Wilfred (OpenClaw Agent) <jay@openclaw.dev>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-27 17:15:59 -08:00
icesword0760
a509154be5 Feishu: send media payloads as attachments (openclaw#28959) thanks @icesword0760
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: icesword0760 <23316247+icesword0760@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 19:06:27 -06:00
Shadow
5cb2a3aa1b Tests: validate discord slash command options 2026-02-27 18:41:16 -06:00
Madoka
32ee2f0109 fix(feishu): break infinite typing-indicator retry loop on rate-limit / quota errors (openclaw#28494) thanks @guoqunabc
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: guoqunabc <9532020+guoqunabc@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 18:41:08 -06:00
Glucksberg
0e755ad99a fix(feishu): use msg_type "audio" for opus files instead of "media" (openclaw#28269) thanks @Glucksberg
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 18:23:19 -06:00
Glucksberg
60ef923051 fix(feishu): cache probeFeishu() results with 10-min TTL to reduce API calls (openclaw#28907) thanks @Glucksberg
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 18:15:28 -06:00
XuHao
56fa05838a feat(feishu): support Docx table create/write + image/file upload actions in feishu_doc (#20304)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 18:00:56 -06:00
大猫子
1725839720 fix(tools): honor tools.fs.workspaceOnly=false for host write/edit (#28822)
Merged via squash.

Prepared head SHA: 83d432961d
Co-authored-by: lailoo <20536249+lailoo@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-02-28 00:53:20 +01:00
OfflynAI
ad804b0356 fix(feishu): propagate mediaLocalRoots for local file sends (#27884) (openclaw#27928) thanks @joelnishanth
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 17:43:57 -06:00
zhoulc777
bf9585d056 PR: Feishu Plugin - Auto-grant document permissions to requesting user (openclaw#28295) thanks @zhoulongchao77
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: zhoulongchao77 <65058500+zhoulongchao77@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 17:34:18 -06:00
Vincent Koc
fa5e71d1ae fix: harden Ollama autodiscovery and warning behavior (#29201)
* agents: auto-discover Ollama models without API key

* tests: cover Ollama autodiscovery warning behavior
2026-02-27 15:22:34 -08:00
Vincent Koc
d17c083803 docs(ollama): clarify /v1 tool-calling guidance (#29204) 2026-02-27 15:21:13 -08:00
Agent
de77497ea8 chore: add convex to sponsors table 2026-02-27 23:27:27 +01:00
Peter Steinberger
4aa2dc6857 fix(infra): land #29078 from @cathrynlavery with restart fallback
Co-authored-by: Cathryn Lavery <cathryn@littlemight.com>
2026-02-27 22:04:46 +00:00
Cathryn Lavery
db67492a00 fix(infra): actively kickstart launchd on supervised gateway restart
When an agent triggers a gateway restart in supervised mode, the process
exits expecting launchd KeepAlive to respawn it. But ThrottleInterval
(default 10s, or 60s on older installs) can delay or prevent restart.

Now calls triggerOpenClawRestart() to issue an explicit launchctl
kickstart before exiting, ensuring immediate respawn. Falls back to
in-process restart if kickstart fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:02:05 +00:00
Sid
ee2eaddeb3 fix(onboard): increase verification timeout and reduce max_tokens for custom provider probes (#27380)
* fix(onboard): increase verification timeout and reduce max_tokens for custom provider probes

The onboard wizard sends a chat-completion request to verify custom
providers.  With max_tokens: 1024 and a 10 s timeout, large local
models (e.g. Qwen3.5-27B on llama.cpp) routinely time out because
the server needs to load the model and generate up to 1024 tokens
before responding.

Changes:
- Raise VERIFY_TIMEOUT_MS from 10 s to 30 s
- Lower max_tokens from 1024 to 1 (verification only needs a single
  token to confirm the API is reachable and the model ID is valid)
- Add explicit stream: false to both OpenAI and Anthropic probes

Closes #27346

Made-with: Cursor

* Changelog: note custom-provider onboarding verification fix

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
2026-02-27 22:51:58 +01:00
Shakker
46d9605ef8 merge-pr: use short squash merge banner 2026-02-27 21:41:24 +00:00
Philipp Spiess
12618c333c tests: complete openai-responses model fixture typing 2026-02-27 22:30:30 +01:00
bmendonca3
f943c76cde security(feishu): bound unauthenticated webhook rate-limit state (openclaw#26050) thanks @bmendonca3
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 13:22:24 -06:00
Bartok Moltbot
3882b8a5be ci: fix CONTRIBUTING.md oxfmt formatting
- Remove trailing blank line after Jonathan Taylor entry
- Escape underscore in @jlehman_ X handle

Fixes #29039
2026-02-27 11:12:04 -08:00
Peter Steinberger
8bc80fad47 fix(slack): land #29032 /agentstatus alias from @maloqab
Land contributor PR #29032 by @maloqab with Slack native alias docs, integration tests, and changelog entry.

Co-authored-by: maloqab <mitebaloqab@gmail.com>
2026-02-27 19:09:38 +00:00
Rodrigo Uroz
1867611733 fix(memory): readonly sync recovery (openclaw#25799) thanks @rodrigouroz
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini (fails in this environment at src/daemon/launchd.integration.test.ts beforeAll hook timeout; merged with Tak override)

Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 12:26:43 -06:00
Josh Lehman
2916152f83 Add contributor Josh Lehman to CONTRIBUTING.md 2026-02-27 12:03:49 -06:00
Vincent Koc
15cf288d73 Update CHANGELOG.md 2026-02-27 09:11:58 -08:00
Peter Steinberger
dede4089a6 docs(openai): add clear server compaction toggle examples 2026-02-27 16:21:08 +00:00
Peter Steinberger
645791c35e ci: add timeout for windows checks job 2026-02-27 16:20:02 +00:00
Peter Steinberger
8da3a9a92d fix(agents): auto-enable OpenAI Responses server-side compaction (#16930, #22441, #25088)
Landed from contributor PRs #16930, #22441, and #25088.

Co-authored-by: liweiguang <codingpunk@gmail.com>
Co-authored-by: EdwardWu7 <wuzhiyuan7@gmail.com>
Co-authored-by: MoerAI <friendnt@g.skku.edu>
2026-02-27 16:15:50 +00:00
Rishabh Jain
6675aacb5e feat(memory-lancedb): Custom OpenAI BaseURL & Dimensions Support (#17874)
* feat(memory-lancedb): add custom baseUrl and dimensions support

* fix(memory-lancedb): strict model typing and safe dimension resolution

* style: fix formatting in memory-lancedb config

* fix(memory-lancedb): sync manifest schema with new embedding options

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.ai>
2026-02-27 07:56:09 -08:00
Vincent Koc
62fa65ec85 Fix npm global install deprecation warnings (#28318)
* Dependencies: make @discordjs/opus an optional peer

* Dependencies: bump node-llama-cpp peer to 3.16.2

* Dependencies: pin Google auth deps to warning-free versions

* CI: reduce Dependabot cooldown to 2 days

* CI: fix invalid Dependabot npm registry config

* CI: restore Dependabot npm registry with token auth

* Dependencies: remove global Google auth pnpm overrides

* CI: make Dependabot updates daily

* Dependencies: restore optional install semantics for @discordjs/opus

* CI: keep Docker Dependabot interval weekly
2026-02-27 07:38:02 -08:00
Peter Steinberger
fe807e4bed chore(release): bump 2026.2.27 and split changelog 2026-02-27 16:09:28 +01:00
Rodrigo Uroz
0fe6cf06b2 Compaction: preserve opaque identifiers in summaries (openclaw#25553) thanks @rodrigouroz
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-27 08:14:05 -06:00
Daniel Reis
84a88b2ace feat(i18n): add German (de) locale (#28495)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: e418326aaf
Co-authored-by: dsantoreis <220753637+dsantoreis@users.noreply.github.com>
Co-authored-by: Evizero <10854026+Evizero@users.noreply.github.com>
Reviewed-by: @Evizero
2026-02-27 11:44:09 +01:00
wangchunyue
6b317b1f17 fix(agents): normalize whitespace-padded tool call names before dispatch (#27094)
Fix tool-call lookup failures when models emit whitespace-padded names by normalizing
both transcript history and live streamed embedded-runner tool calls before dispatch.

Co-authored-by: wangchunyue <80630709+openperf@users.noreply.github.com>
Co-authored-by: Sid <sidqin0410@gmail.com>
Co-authored-by: Philipp Spiess <hello@philippspiess.com>
2026-02-27 11:26:37 +01:00
Ayaan Zaidi
aae90cb036 fix(telegram): include replied media files in reply context (#28488)
* fix(telegram): include replied media files in reply context

* fix(telegram): keep reply media fields nullable

* perf(telegram): defer reply-media fetch to debounce flush

* fix(telegram): gate and preserve reply media attachments

* fix(telegram): preserve cached-sticker reply media context

* fix: update changelog for telegram reply-media context fixes (#28488) (thanks @obviyus)
2026-02-27 15:16:21 +05:30
Onur Solmaz
a7929abad8 Discord: thread bindings idle + max-age lifecycle (#27845) (thanks @osolmaz)
* refactor discord thread bindings to idle and max-age lifecycle

* fix: migrate legacy thread binding expiry and reduce hot-path disk writes

* refactor: remove remaining thread-binding ttl legacy paths

* fix: harden thread-binding lifecycle persistence

* Discord: fix thread binding types in message/reply paths

* Infra: handle win32 unknown inode in file identity checks

* Infra: relax win32 guarded-open identity checks

* Config: migrate threadBindings ttlHours to idleHours

* Revert "Infra: relax win32 guarded-open identity checks"

This reverts commit de94126771.

* Revert "Infra: handle win32 unknown inode in file identity checks"

This reverts commit 96fc5ddfb3.

* Discord: re-read live binding state before sweep unbind

* fix: add changelog note for thread binding lifecycle update (#27845) (thanks @osolmaz)

---------

Co-authored-by: Onur Solmaz <onur@textcortex.com>
2026-02-27 10:02:39 +01:00
Ayaan Zaidi
0fb7add7d6 fix: document canvas capability refresh params fix (#28413) (thanks @obviyus) 2026-02-27 13:26:42 +05:30
Ayaan Zaidi
3a35035512 fix(android): send object params for canvas capability refresh 2026-02-27 13:26:42 +05:30
Ayaan Zaidi
256021b8da fix: update changelog for android capability refresh land (#28388) (thanks @obviyus) 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
6222d6650b fix(android): avoid duplicate A2UI readiness probe on happy path 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
8187fbc571 fix(android): refresh scoped canvas URLs without trailing slash 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
4b37b7b6a9 fix(media): serve JavaScript assets with text/javascript 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
d53b24d185 fix(android): return valid debug.ed25519 diagnostics JSON 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
34486f8c10 fix(android): retry A2UI after canvas capability refresh 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
9b64ad30c4 docs(android): add integration test preconditions and pitfalls 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
72adf1e993 test(gateway): add live android capability integration suite 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
54eaf17327 feat(gateway): add node canvas capability refresh flow 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
0896bb09b0 feat(android): wire runtime canvas capability refresh 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
6ed00abc1e docs: document android capability sweep in testing guide 2026-02-27 12:16:36 +05:30
Ayaan Zaidi
7f6e822526 test: add android integration test script 2026-02-27 12:16:36 +05:30
Byungsker
d911b0254d fix(agents): demote Ollama empty-discovery log from warn to debug (#26379)
When Ollama responds successfully but returns zero models (e.g. on Linux
with the bundled `ollama-stub.service`), `discoverOllamaModels` was
logging at `warn` level:

  [agents/model-providers] No Ollama models found on local instance

This appeared on every agent invocation even when Ollama was not
intentionally configured, polluting production logs.  An empty model
list is a normal operational state — it warrants at most a debug
note, not a warning.

Fix: change `log.warn` → `log.debug` for the zero-models branch.
The error paths (HTTP failure, fetch exception) remain at `warn`
since those indicate genuine connectivity problems.

Closes #26354
2026-02-26 21:12:10 -08:00
Vincent Koc
cb9374a2a1 Gateway: improve device-auth v2 migration diagnostics (#28305)
* Gateway: add device-auth detail code resolver

* Gateway: emit specific device-auth detail codes

* Gateway tests: cover nonce and signature detail codes

* Docs: add gateway device-auth migration diagnostics

* Docs: add device-auth v2 troubleshooting signatures
2026-02-26 21:05:43 -08:00
Vincent Koc
22ad7523f1 Docker: replace npm link with root CLI symlink (#28312) 2026-02-26 23:57:28 -05:00
Vincent Koc
e8e673992a CI: smoke test root Dockerfile openclaw CLI (#28308) 2026-02-26 23:54:17 -05:00
Yutaka Sasaki
f5adb66bbc fix: add npm link to fix CLI permission denied (exit 127) (#17151)
Co-authored-by: Yutaka Sasaki <sskyu@minio.local>
2026-02-26 23:47:45 -05:00
Ayaan Zaidi
2719398dd9 docs(changelog): note android node diagnostics and action updates (#28260) (thanks @obviyus) 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
22d422a792 refactor(android-node): share battery snapshot parsing across device handlers 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
284f75500c refactor(android-node): unify notifications snapshot rebind preflight 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
1bf08ae7c9 refactor(nodes): map read actions to invoke commands 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
bbab0b005e fix(android): rebind listener before notification actions 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
8807267bfd fix(android): allow open and reply on non-clearable notifications 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
b8373eaddc fix(nodes): reject facing=both when camera deviceId is set 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
29f5da5b2a feat(nodes): expose device diagnostics and notification actions 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
d0ec3de588 feat(android): implement device diagnostics and notification actions 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
e99b323a6b feat(node): add device diagnostics and notification action commands 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
e48513d512 fix(android): scale invoke result ack timeout to invoke budget 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
c1e0f8cfb1 docs(nodes): document android camera list and device actions 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
01f1d355a4 feat(nodes): add device status and info actions 2026-02-27 10:15:21 +05:30
Ayaan Zaidi
1f7b3c613d feat(android): add camera list and device selection 2026-02-27 10:15:21 +05:30
Vincent Koc
c838a4dde0 Changelog: add missing npm update and plugin fix credits (#28257) 2026-02-26 22:52:50 -05:00
Ayaan Zaidi
de885d260f fix: update changelog for android camera clip (#28229) (thanks @obviyus) 2026-02-27 09:10:10 +05:30
Ayaan Zaidi
0f7664fda3 fix(android): reject non-positive camera maxWidth 2026-02-27 09:10:10 +05:30
Ayaan Zaidi
adb41e48ae test(android): cover camera clip payload size guard 2026-02-27 09:10:10 +05:30
Ayaan Zaidi
fb34c46074 refactor(android): make camera clip transport deterministic 2026-02-27 09:10:10 +05:30
Ayaan Zaidi
120a7abbab test(android): cover camera clip upload URL JSON parsing 2026-02-27 09:10:10 +05:30
Ayaan Zaidi
67609cc16f fix(android): parse camera and screen invoke params as JSON 2026-02-27 09:10:10 +05:30
Vincent Koc
88a0d87490 Docs: align gateway config key paths with metadata (#28196)
* Docs: align gateway config key paths in reference

* Docs: expand config reference coverage for channels plugins and providers
2026-02-26 22:35:43 -05:00
Dale Yarborough
efdba59e49 fix(plugins): clear error when npm package not found (Closes #24993) (#25073) 2026-02-26 22:16:28 -05:00
graysurf
7aa233790b Fix npm-spec plugin installs when npm pack output is empty (#21039)
* fix(plugins): recover npm pack archive when stdout is empty

* test(plugins): create npm pack archive in metadata mock

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-26 22:00:24 -05:00
Ayaan Zaidi
9d52dcf1f4 fix: stabilize launchd CA env tests (#27915) (thanks @Lukavyi) 2026-02-27 08:11:16 +05:30
clawdbot
6b59c87570 fix: add missing closing brace in proxy env test 2026-02-27 08:11:16 +05:30
Clawborn
d33f24c4e9 Fix NODE_EXTRA_CA_CERTS missing from LaunchAgent environment on macOS
launchd services do not inherit the shell environment, so Node's undici/fetch
cannot locate the macOS system CA bundle (/etc/ssl/cert.pem). This causes TLS
verification failures for all HTTPS requests (e.g. Telegram, webhooks) when the
gateway runs as a LaunchAgent, while the same gateway works fine in a terminal.

Add NODE_EXTRA_CA_CERTS defaulting to /etc/ssl/cert.pem on macOS in both
buildServiceEnvironment and buildNodeServiceEnvironment. User-supplied
NODE_EXTRA_CA_CERTS is always respected and takes precedence.

Fixes #22856

Co-authored-by: Clawborn <tianrun.yang103@gmail.com>
2026-02-27 08:11:16 +05:30
Xinhua Gu
7bbfb9de5e fix(update): fallback to --omit=optional when global npm update fails (#24896)
* fix(update): fallback to --omit=optional when global npm update fails

* fix(update): add recovery hints and fallback for npm global update failures

* chore(update): align fallback progress step index ordering

* chore(update): label omit-optional retry step in progress output

* chore(update): avoid showing 1/2 when fallback path is not used

* chore(ci): retrigger after unrelated test OOM

* fix(update): scope recovery hints to npm failures

* test(update): cover non-npm hint suppression

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-26 21:35:13 -05:00
Ayaan Zaidi
418111adb9 docs(telegram): align channel docs with runtime behavior 2026-02-27 08:00:29 +05:30
Ayaan Zaidi
7149ba5574 docs: remove legacy grammy page 2026-02-27 08:00:29 +05:30
Ayaan Zaidi
035a2dbb40 docs: consolidate grammy links to telegram 2026-02-27 08:00:29 +05:30
Ayaan Zaidi
1f68010bd6 docs(telegram): clarify group auth boundary 2026-02-27 08:00:29 +05:30
Philipp Spiess
35e40f1139 ui: remove Google Fonts import blocked by CSP (style-src 'self' 'unsafe-inline'); fonts never loaded; closes #28038 2026-02-27 01:44:41 +01:00
Peter Steinberger
5c776be60b test: stabilize docker live model suites 2026-02-27 01:21:45 +01:00
Peter Steinberger
bc50708057 chore(release): cut 2026.2.26 2026-02-27 00:58:16 +01:00
Sid
e6be26ef1c fix(provider): normalize bare gemini-3 Pro model IDs for google-antigravity (#24145)
* fix(provider): normalize bare gemini-3 Pro model IDs for google-antigravity

The Antigravity Cloud Code Assist API requires a thinking-tier suffix
(-low or -high) for all Gemini 3 Pro variants.  When a user configures
a bare model ID like `gemini-3.1-pro`, the API returns a 404 because it
only recognises `gemini-3.1-pro-low` or `gemini-3.1-pro-high`.

Add `normalizeAntigravityModelId()` that appends `-low` (the default
tier) to bare Pro model IDs, and apply it during provider normalisation
for `google-antigravity`.  Also refactor the per-provider model
normalisation into a shared `normalizeProviderModels()` helper.

Closes #24071

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tests: cover antigravity model ID normalization

* Changelog: note antigravity pro tier normalization

* Tests: type antigravity model helper inputs

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-26 18:53:46 -05:00
Byungsker
17578d77e1 fix(agents): add forward-compat fallback for google-gemini-cli gemini-3.1-pro/flash-preview (#26570)
* fix(agents): add "google" provider to isReasoningTagProvider to prevent reasoning leak

The gemini-api-key auth flow creates a profile with provider "google"
(e.g. google/gemini-3-pro-preview), but isReasoningTagProvider only
matched "google-gemini-cli" (OAuth) and "google-generative-ai". As a
result:
- reasoningTagHint was false → system prompt omitted <think>/<final>
  formatting instructions
- enforceFinalTag was false → <final> tag filtering was skipped

Raw <think> reasoning output was delivered to the end user.

Fix: add the bare "google" provider string to the match list and cover
it with two new test cases (exact match + case-insensitive).

Fixes #26551

* fix(agents): add forward-compat fallback for google-gemini-cli gemini-3.1-pro/flash-preview

gemini-3.1-pro-preview and gemini-3.1-flash-preview are not yet present in
pi-ai's built-in google-gemini-cli model catalog (only gemini-3-pro-preview
and gemini-3-flash-preview are registered). When users configure these models
they get "Unknown model" errors even though Gemini CLI OAuth supports them.

The codebase already has isGemini31Model() in extra-params.ts, which proves
intent to support these models. Add a resolveGoogleGeminiCli31ForwardCompatModel
entry to resolveForwardCompatModel following the same clone-template pattern
used for zai/glm-5 and anthropic 4.6 models.

- gemini-3.1-pro-* clones gemini-3-pro-preview (with reasoning: true)
- gemini-3.1-flash-* clones gemini-3-flash-preview (with reasoning: true)

Also add test helpers and three test cases to model.forward-compat.test.ts.

Fixes #26524

* Changelog: credit Google Gemini provider fallback fixes

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-26 18:39:13 -05:00
Philipp Spiess
d320b30b9b Docs: expand ACP first-use naming and link protocol site 2026-02-27 00:33:58 +01:00
Peter Steinberger
297cca0565 docs(cli): improve secrets command guide 2026-02-27 00:20:02 +01:00
Peter Steinberger
1d43202930 fix: repair Telegram allowlist DM migrations (#27936) (thanks @widingmarcus-cyber) 2026-02-26 22:53:13 +00:00
Vincent Koc
2c6b078ff0 Changelog: include Gemini OAuth PRs #16683 and #16684 (#27987) 2026-02-26 17:50:53 -05:00
Peter Steinberger
7dad7cc2ca fix(ci): align sync boundary realpath canonicalization 2026-02-26 23:48:38 +01:00
Peter Steinberger
5b62d5603d fix: unblock CI minimatch audit and host policy check 2026-02-26 22:48:09 +00:00
Peter Steinberger
c35368c6dd fix(ios): eliminate Swift warnings and clean build logs 2026-02-26 22:42:23 +00:00
Peter Steinberger
22c74d416b chore(release): point appcast to beta tag 2026-02-26 23:38:20 +01:00
Peter Steinberger
80d44c983f chore(release): cut 2026.2.26-beta.1 2026-02-26 23:10:47 +01:00
Peter Steinberger
90c6744925 docs(changelog): reorder docker gateway fix by user impact 2026-02-26 23:06:40 +01:00
Philipp Spiess
a29b18c003 Protocol: regenerate Swift models for systemRunPlanV2 2026-02-26 23:05:23 +01:00
Peter Steinberger
45d868685f fix: enforce dm allowFrom inheritance across account channels (#27936) (thanks @widingmarcus-cyber) 2026-02-26 22:04:16 +00:00
Marcus Widing
0fdac31383 fix: skip allowFrom validation at account level (inherits from parent)
Account configs inherit channel-level fields at runtime (e.g.,
resolveTelegramAccount shallow-merges top-level and account values).
An account can set dmPolicy='allowlist' and rely on the parent's
allowFrom, so validating allowFrom on the account object alone
incorrectly rejects valid multi-account configs.

Removes requireAllowlistAllowFrom and requireOpenAllowFrom from all
account-level schemas (Telegram, Signal, IRC, iMessage, BlueBubbles).
Top-level config schemas still enforce the validation.

Addresses Codex review feedback on #27936.
2026-02-26 22:04:16 +00:00
Marcus Widing
cbed0e065c fix: reject dmPolicy="allowlist" with empty allowFrom across all channels
When dmPolicy is set to "allowlist" but allowFrom is missing or empty,
all DMs are silently dropped because no sender can match the empty
allowlist. This is a common pitfall after upgrades that change how
allowlist files are handled (e.g., external allowlist-dm.json files
being deprecated in favor of inline allowFrom arrays).

Changes:
- Add requireAllowlistAllowFrom schema refinement (zod-schema.core.ts)
- Apply validation to all channel schemas: Telegram, Discord, Slack,
  Signal, IRC, iMessage, BlueBubbles, MS Teams, Google Chat, WhatsApp
- Add detectEmptyAllowlistPolicy to doctor-config-flow.ts so
  "openclaw doctor" surfaces a clear warning with remediation steps
- Add 12 test cases covering reject/accept for multiple channels

Fixes #27892
2026-02-26 22:04:16 +00:00
Peter Steinberger
e618794a96 test: align compaction hook usage expectation 2026-02-26 22:03:26 +00:00
Peter Steinberger
39f7dbfe02 fix(cli): make gateway --force resilient to lsof EACCES 2026-02-26 23:02:58 +01:00
Peter Steinberger
c03adfb41a test: align compaction hook usage expectation 2026-02-26 22:00:31 +00:00
Peter Steinberger
31c0b04c49 fix(nextcloud-talk): keep startAccount pending until abort (#27897) 2026-02-26 22:00:25 +00:00
Peter Steinberger
b1bbf3fff1 fix: harden temp dir perms for umask 0002 (landed from #27860 by @stakeswky)
Co-authored-by: 不做了睡大觉 <stakeswky@gmail.com>
2026-02-26 21:59:55 +00:00
Peter Steinberger
53575f2013 fix: add googlechat lifecycle regression test (#27384) (thanks @junsuwhy) 2026-02-26 21:49:26 +00:00
Chang Shu-Huai
eb6fa0dacf fix(googlechat): keep startAccount pending until abort to prevent restart loop 2026-02-26 21:49:26 +00:00
Peter Steinberger
cb917b7f05 chore: silence onboard warning noise 2026-02-26 22:47:35 +01:00
Peter Steinberger
10c7ae1eca refactor(outbound): split recovery counters and normalize legacy retry entries 2026-02-26 22:42:15 +01:00
Peter Steinberger
5dd264d2fb refactor(daemon): unify runtime binary detection 2026-02-26 22:39:05 +01:00
Peter Steinberger
58171c8918 docs(security): clarify parity-only command-risk reports 2026-02-26 22:37:12 +01:00
Peter Steinberger
cceefe833a fix: harden delivery recovery backoff eligibility and tests (#27710) (thanks @Jimmy-xuzimo) 2026-02-26 21:37:00 +00:00
Xu Zimo
0cfd448bab fix(delivery-queue): change break to continue to prevent head-of-line blocking
When an entry's backoff exceeds the recovery budget, the code was using
break which blocked all subsequent entries from being processed. This
caused permanent queue blockage for any installation with a delivery entry
at retryCount >= 2.

Fix: Changed break to continue so entries whose backoff exceeds the
remaining budget are skipped individually rather than blocking the
entire loop.

Closes #27638
2026-02-26 21:37:00 +00:00
SidQin-cyber
27f4ab2fb2 fix(models): extend gpt-5.3-codex forward compat to github-copilot
The codex forward-compat fallback only matched openai-codex, leaving
github-copilot users without gpt-5.3-codex despite the model being
available on the Copilot API.

Made-with: Cursor
2026-02-26 21:36:57 +00:00
Peter Steinberger
564be6b402 refactor(channels): unify dm pairing policy flows 2026-02-26 22:36:20 +01:00
Peter Steinberger
7e0b3f16e3 fix: preserve assistant usage snapshots during compaction cleanup 2026-02-26 21:35:26 +00:00
Peter Steinberger
ca2ae342db fix(cli): accept node24 executable names in argv reparse 2026-02-26 22:35:04 +01:00
Peter Steinberger
d33db186d0 docs: reorder unreleased 2026.2.26 changelog entries 2026-02-26 22:30:13 +01:00
Peter Steinberger
da61aa8a58 test: fix TS2783 in nodes-utils helper 2026-02-26 21:26:54 +00:00
Peter Steinberger
c53b11dccd test: fix pairing/daemon assertion drift 2026-02-26 21:24:50 +00:00
Peter Steinberger
a1346a519a refactor(nodes): share default selection and tighten node.list fallback 2026-02-26 22:18:57 +01:00
Peter Steinberger
7ef6623bf3 fix: forward resolved session key in agent delivery (follow-up #27584 by @qualiobra)
Co-authored-by: Lucas Teixeira Campos Araujo <lucas@MacBook-Pro-de-Lucas.local>
2026-02-26 21:18:15 +00:00
Peter Steinberger
eaa9e1c661 refactor(browser): unify fill field normalization 2026-02-26 22:17:58 +01:00
Peter Steinberger
69b2f8cd8b docs(changelog): credit reporter for pairing isolation fix 2026-02-26 22:14:32 +01:00
Peter Steinberger
df65ed7e9e test(gateway): align outbound session assertion shape 2026-02-26 22:14:32 +01:00
Peter Steinberger
2ed9d633b3 fix: browser fill default type parity (#27662) (thanks @Uface11) 2026-02-26 21:14:28 +00:00
Rick
a0b12f2ba7 fix(browser): accept fill fields without explicit type
Default missing fill field type to 'text' in /act route to avoid spurious 'fields are required' failures from relay/tool callers. Add regression test for fill payloads with ref+value only.
2026-02-26 21:14:28 +00:00
Peter Steinberger
712e231725 fix(agent): forward resolved outbound session context for delivery 2026-02-26 22:14:22 +01:00
Peter Steinberger
da9f24dd2e fix: add nodes default-node regression test (#27444) (thanks @carbaj03) 2026-02-26 21:13:19 +00:00
ACV
47bb568cb2 fix(nodes): resolve default node when multiple canvas-capable nodes are connected
`pickDefaultNode()` returned null when multiple connected canvas-capable
nodes existed and none matched the local Mac heuristic. This caused
"node required" errors for agents (especially sub-agents) calling the
canvas tool without an explicit node parameter.

In multi-node setups, any canvas-capable node is a valid target — the
receiving node broadcasts A2UI surfaces to all other connected devices.
Fall back to the first connected candidate instead of failing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-26 21:13:19 +00:00
Peter Steinberger
4b4718c8df refactor(cli): decompose nodes run approval flow 2026-02-26 22:01:27 +01:00
Peter Steinberger
4e690e09c7 refactor(gateway): centralize system.run approval context and errors 2026-02-26 22:01:16 +01:00
Peter Steinberger
d06632ba45 refactor(gateway): share node command catalog 2026-02-26 22:01:06 +01:00
Peter Steinberger
d82c042b09 refactor(node-host): split system.run plan and allowlist internals 2026-02-26 22:01:01 +01:00
Peter Steinberger
bce643a0bd refactor(security): enforce account-scoped pairing APIs 2026-02-26 21:57:52 +01:00
Peter Steinberger
a0c5e28f3b refactor(extensions): use scoped pairing helper 2026-02-26 21:57:52 +01:00
Peter Steinberger
36b6ea1446 docs: enforce repo-relative file refs in AGENTS 2026-02-26 21:57:52 +01:00
Peter Steinberger
192df12d60 test(voice-call): cover verification key and header helpers 2026-02-26 21:54:09 +01:00
Peter Steinberger
535ef8991c refactor(voice-call): enforce verified webhook key contract 2026-02-26 21:54:09 +01:00
Peter Steinberger
6f0b4caa26 refactor(voice-call): share header and guarded api helpers 2026-02-26 21:54:09 +01:00
Peter Steinberger
78a7ff2d50 fix(security): harden node exec approvals against symlink rebind 2026-02-26 21:47:45 +01:00
Peter Steinberger
611dff985d fix(agents): harden embedded pi project settings loading 2026-02-26 21:46:39 +01:00
Peter Steinberger
38b6cee020 feat(config): add embedded pi project settings policy 2026-02-26 21:46:39 +01:00
Peter Steinberger
1aadf26f9a fix(voice-call): bind webhook dedupe to verified request identity 2026-02-26 21:43:51 +01:00
Vincent Koc
5a453eacbd chore(onboarding): add explicit account-risk warning for Gemini CLI OAuth and docs (#16683)
* docs: add account-risk caution to Google OAuth provider docs

* docs(plugin): add Gemini CLI account safety caution

* CLI: add risk hint for Gemini CLI auth choice

* Onboarding: require confirmation for Gemini CLI OAuth

* Tests: cover Gemini CLI OAuth risk confirmation flow
2026-02-26 15:25:42 -05:00
Vincent Koc
764cd5a310 fix(gemini-oauth): align OAuth project discovery metadata and endpoint fallbacks (#16684)
* fix(gemini-oauth): align loadCodeAssist metadata and endpoint fallback

* test(gemini-oauth): cover endpoint fallback and env project fallback

* fix(gemini-oauth): route timed fetches through ssrf guard

* test(gemini-oauth): mock guarded fetch in oauth tests
2026-02-26 15:24:35 -05:00
Peter Steinberger
a1628d89ec refactor: unify outbound session context wiring 2026-02-26 21:03:28 +01:00
Peter Steinberger
8483e01a68 refactor(matrix): dedupe sender label resolution for inbound bodies 2026-02-26 20:57:05 +01:00
Peter Steinberger
01b4f42f9a fix(matrix): preserve sender labels in Matrix BodyForAgent 2026-02-26 20:57:05 +01:00
Peter Steinberger
4cb4053993 fix: complete sessionKey forwarding for message:sent hook (#27584) (thanks @qualiobra) 2026-02-26 19:56:27 +00:00
Lucas Teixeira Campos Araujo
a4408a917e fix: pass sessionKey to deliverOutboundPayloads for message:sent hook dispatch
Several call sites of deliverOutboundPayloads() were not passing the
sessionKey parameter, causing the internal message:sent hook to never
fire (the guard `if (!sessionKeyForInternalHooks) return` in deliver.ts
silently skipped the triggerInternalHook call).

Fixed call sites:
- commands/agent/delivery.ts (agent loop replies — main fix)
- infra/heartbeat-runner.ts (heartbeat OK + alert delivery)
- infra/outbound/message.ts (message tool sends)
- cron/isolated-agent/delivery-dispatch.ts (cron job delivery)
- gateway/server-node-events.ts (node event forwarding)

The sessionKey parameter already existed in DeliverOutboundPayloadsCoreParams
and was used by deliver.ts to emit the message:sent internal hook event,
but was simply not being passed from most callers.
2026-02-26 19:56:27 +00:00
Taras Shynkarenko
20730af20b fix(browser): stop wrapping application errors with Can't reach message 2026-02-26 19:55:39 +00:00
Vincent Koc
311f57a2cd Changelog: add entries for PR #12849 and #27585 (#27887) 2026-02-26 14:54:48 -05:00
Peter Steinberger
675764e866 refactor(tui): simplify stream boundary-drop modes 2026-02-26 20:54:29 +01:00
Peter Steinberger
b01273cfc6 fix: narrow finalize boundary-drop guard (#27711) (thanks @scz2011) 2026-02-26 19:50:06 +00:00
AI Assistant
d6cbaea434 fix(tui): preserve streamed text during tool call transitions
Fixes #27674

The TUI was erasing already-streamed assistant text when tool calls
were triggered. This happened because the finalize() method in
TuiStreamAssembler was not using the protectBoundaryDrops option
when updating run state.

Now finalize() applies the same boundary drop protection as
ingestDelta(), ensuring that streamed text before tool calls is
preserved when the final payload drops earlier content blocks.
2026-02-26 19:50:06 +00:00
Shadow
03159f3942 CI: add maintainer ping auto-response 2026-02-26 13:30:12 -06:00
Peter Steinberger
344f54b84d refactor(config): dedupe model api definitions 2026-02-26 20:00:11 +01:00
Peter Steinberger
ac03803d12 fix: align codex model api schema/type coverage (#27501) (thanks @AytuncYildizli) 2026-02-26 18:51:04 +00:00
AytuncYildizli
861b90f79c fix(config): add openai-codex-responses to ModelApiSchema
The config schema validates provider api fields against ModelApiSchema,
but openai-codex-responses was missing from the allowed values. This
forces users to set api: "openai-responses" for the openai-codex
provider, which routes requests to api.openai.com/v1/responses instead
of chatgpt.com/backend-api/codex/responses, causing HTTP 401 errors
because Codex OAuth tokens lack api.responses.write scope for the
standard OpenAI Responses endpoint.

The runtime already supports openai-codex-responses throughout: model
registry, stream dispatch (streamOpenAICodexResponses), and provider
detection (OPENAI_MODEL_APIS set). Only the config schema was missing
the literal.
2026-02-26 18:51:04 +00:00
Peter Steinberger
d92fc85555 refactor(cli): dedupe gateway run mode parsing 2026-02-26 19:50:49 +01:00
Shakker
f7041fbee3 fix(windows): normalize namespaced path containment checks 2026-02-26 18:49:48 +00:00
Peter Steinberger
dc6e4a5b13 fix: harden dm command authorization in open mode 2026-02-26 19:49:36 +01:00
Nimrod Gutman
3f20c43308 fix: add nimrod gutman maintainer profile (#27840) (thanks @ngutman) 2026-02-26 20:46:37 +02:00
Viz
a81cf35a6f Add contributor Jonathan Taylor to CONTRIBUTING.md
Added Jonathan Taylor's contributions and contact links.
2026-02-26 13:22:34 -05:00
Peter Steinberger
a909019078 fix: align gateway run auth modes (#27469) (thanks @s1korrrr) 2026-02-26 18:20:27 +00:00
Rafal
1087033abd fix(cli): list all supported auth modes in gateway run --auth help
Made-with: Cursor
2026-02-26 18:20:27 +00:00
Shakker
47f52cd233 test(cli): tighten daemon status TLS mock typings 2026-02-26 18:13:33 +00:00
Shakker
bed69339c1 fix(cli): scope daemon status TLS fingerprint to local probes 2026-02-26 18:13:33 +00:00
Shakker
b788616d9c fix(cli): add TLS daemon-status probe regression coverage 2026-02-26 18:13:33 +00:00
Liu Yuan
90d426f9ad fix(cli): gateway status probe with TLS when bind=lan
- Use wss:// scheme when TLS is enabled (specifically for bind=lan)
- Load TLS runtime to get certificate fingerprint
- Pass fingerprint to probeGatewayStatus for self-signed cert trust
2026-02-26 18:13:33 +00:00
Peter Steinberger
d6eefe2e75 style: format auth boundary updates 2026-02-26 18:50:47 +01:00
Peter Steinberger
262bca9bdd fix: restore dm command and self-chat auth behavior 2026-02-26 18:49:16 +01:00
Peter Steinberger
64de4b6d6a fix: enforce explicit group auth boundaries across channels 2026-02-26 18:49:16 +01:00
Shakker
d0d83a2020 docs(changelog): add PR #17017 entry 2026-02-26 17:10:09 +00:00
Shakker
fe842b5f14 test(auto-reply): cover inbound timestamp guard 2026-02-26 17:10:09 +00:00
Liu Yuan
c596658b8d feat(auto-reply): make agent time-aware with message timestamps
Add human-readable timestamp field to the Conversation info JSON block.

Before:
  {
    "conversation_label": "D id:123"
  }

After:
  {
    "conversation_label": "D id:123",
    "timestamp": "Sun 2026-02-15 13:35 GMT+8"
  }

Benefits:
- Better time awareness for time-related questions
- Understand conversation gaps and response delays
- Handle delayed message delivery
- Context for relative time references ("just now", "later")
2026-02-26 17:10:09 +00:00
Peter Steinberger
10481097f8 refactor(security): enforce v1 node exec approval binding 2026-02-26 18:09:01 +01:00
Peter Steinberger
f4391c1725 docs(security): clarify Teams fileConsent uploadUrl report scope 2026-02-26 17:58:38 +01:00
Peter Steinberger
9597cf1890 docs(security): scope obfuscation parity reports as hardening 2026-02-26 17:58:25 +01:00
joshavant
edf7ad9b7d add me to Maintainers list
Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-02-26 10:55:03 -06:00
Peter Steinberger
9f154efa8d docs(acp): expand /acp operator playbook 2026-02-26 16:49:20 +00:00
Peter Steinberger
c5facb8477 fix(discord): avoid invalid /acp native option payload 2026-02-26 16:49:20 +00:00
Peter Steinberger
cd80c7e7ff refactor: unify dm policy store reads and reason codes 2026-02-26 17:47:57 +01:00
Peter Steinberger
53e30475e2 test(agents): add compaction and workspace reset regressions 2026-02-26 17:41:25 +01:00
Peter Steinberger
0ec7711bc2 fix(agents): harden compaction and reset safety
Co-authored-by: jaden-clovervnd <91520439+jaden-clovervnd@users.noreply.github.com>
Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com>
2026-02-26 17:41:24 +01:00
Peter Steinberger
273973d374 refactor: unify typing dispatch lifecycle and policy boundaries 2026-02-26 17:36:16 +01:00
Peter Steinberger
6fd9ec97de fix(gateway): preserve turn-origin messageChannel in agent runs 2026-02-26 17:25:56 +01:00
Peter Steinberger
08e3357480 refactor: share gateway security path canonicalization 2026-02-26 17:23:46 +01:00
Shakker
15e3e63705 protocol: regenerate Swift models for exec env field 2026-02-26 16:19:44 +00:00
Shakker
b044c149c1 Mattermost: avoid raw fetch in monitor media download 2026-02-26 16:03:39 +00:00
Peter Steinberger
8a51891ed5 test(exec-approvals): cover v1 binding precedence and mismatch mapping 2026-02-26 17:02:52 +01:00
Peter Steinberger
258d615c4d fix: harden plugin route auth path canonicalization 2026-02-26 17:02:06 +01:00
Peter Steinberger
37a138c554 fix: harden typing lifecycle and cross-channel suppression 2026-02-26 17:01:09 +01:00
Peter Steinberger
4894d907fa refactor(exec-approvals): unify system.run binding and generate host env policy 2026-02-26 16:58:01 +01:00
Ayaan Zaidi
baf1c8ea13 docs: add changelog for android device node commands (#27664) (thanks @obviyus) 2026-02-26 21:26:11 +05:30
Ayaan Zaidi
cf327f60ba fix(android): require validated network for device status 2026-02-26 21:26:11 +05:30
Ayaan Zaidi
d14e734e9c refactor(android): remove dead thermal sdk branch 2026-02-26 21:26:11 +05:30
Ayaan Zaidi
d768c1f81c feat(android): wire device commands into runtime 2026-02-26 21:26:11 +05:30
Ayaan Zaidi
67f6a13c5a feat(android): add device status and info handler 2026-02-26 21:26:11 +05:30
Ayaan Zaidi
551647aa96 feat(android): add device invoke protocol commands 2026-02-26 21:26:11 +05:30
riccoyuanft
60bb475355 fix: set authHeader: true by default for MiniMax API provider (#27622)
* Update onboard-auth.config-minimax.ts

fix issue #27600

* fix(minimax): default authHeader for implicit + onboarding providers (#27600)

Landed from contributor PR #27622 by @riccoyuanft and PR #27631 by @kevinWangSheng.
Includes a small TS nullability guard in lane delivery to keep build green on rebased head.

Co-authored-by: riccoyuanft <riccoyuan@gmail.com>
Co-authored-by: Kevin Shenghui <shenghuikevin@github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Kevin Shenghui <shenghuikevin@github.com>
2026-02-26 15:53:51 +00:00
Peter Steinberger
1708b11fab refactor(pi): simplify image reference detection 2026-02-26 16:52:13 +01:00
Peter Steinberger
b678308d96 docs: add unreleased security note for msteams ssrf hardening 2026-02-26 16:48:32 +01:00
Peter Steinberger
75ed72e807 refactor(pi): extract history image prune helpers 2026-02-26 16:44:52 +01:00
Peter Steinberger
57334cd7d8 refactor: unify channel/plugin ssrf fetch policy and auth fallback 2026-02-26 16:44:13 +01:00
Peter Steinberger
2e97d0dd95 fix: finalize teams file-consent timeout landing (#27641) (thanks @scz2011) 2026-02-26 15:42:08 +00:00
AI Assistant
773ab319ef fix(msteams): Fix code formatting
Remove trailing whitespace to pass oxfmt format check.
2026-02-26 15:42:08 +00:00
AI Assistant
ecbb3bcc1a fix(msteams): Fix test timing for async file upload handling
Update tests to properly wait for async file upload operations:
- Use vi.waitFor() to wait for async upload completion in success case
- Use vi.waitFor() to wait for error message in cross-conversation case
- Add setTimeout delay for decline case to ensure async handler completes
- Adjust assertion order to match new execution flow (invokeResponse first)

The tests were failing because the file upload now happens asynchronously
after sending the invokeResponse, so we need to explicitly wait for the
async operations to complete before making assertions.
2026-02-26 15:42:08 +00:00
AI Assistant
09f4abdd61 fix(msteams): Send invokeResponse immediately to prevent Teams timeout (#27632)
Fix file upload 'Something went wrong' error by sending the invoke
acknowledgement before performing the file upload, rather than after.

Changes:
- Move invokeResponse to fire immediately upon receiving fileConsent/invoke
- Handle file upload asynchronously without blocking the response
- Update test to wait for async upload completion using vi.waitFor

This prevents Teams from timing out while waiting for the HTTP 200
acknowledgement during slow file uploads to OneDrive.

Fixes #27632
2026-02-26 15:42:08 +00:00
Peter Steinberger
7d9397099b fix(bluebubbles): allow configured host for attachment SSRF guard
Co-authored-by: damaozi <1811866786@qq.com>
2026-02-26 16:40:57 +01:00
Peter Steinberger
4da6a7f212 refactor(restart): extract stale pid cleanup and supervisor markers 2026-02-26 16:39:27 +01:00
Peter Steinberger
c81e9866ff fix(pi): stop history image reinjection token blowup 2026-02-26 16:38:20 +01:00
Peter Steinberger
9a4b2266cc fix(security): bind node system.run approvals to env 2026-02-26 16:38:07 +01:00
Peter Steinberger
f877e7e74c fix(telegram): split stop-created preview finalization path
Refactor lane preview finalization into explicit branches so stop-created
previews never duplicate sends when edit fails.

Add Telegram dispatch regressions for:
- stop-created preview edit failure (no duplicate send)
- existing preview edit failure (fallback send preserved)
- missing message id after stop-created flush (fallback send)

Thanks @obviyus for the original preview-prime direction in #27449.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-02-26 15:35:41 +00:00
Peter Steinberger
051fdcc428 fix(security): centralize dm/group allowlist auth composition 2026-02-26 16:35:33 +01:00
Peter Steinberger
7f863e22b0 docs(changelog): unify gateway restart-loop fixes 2026-02-26 15:31:04 +00:00
Kevin Shenghui
16ccd5a874 fix(gateway): add ThrottleInterval to launchd plist to prevent restart loop 2026-02-26 15:31:04 +00:00
Peter Steinberger
ed9cd846d0 chore(deps): refresh grammy and @types/node 2026-02-26 16:22:53 +01:00
Peter Steinberger
03d7641b0e feat(agents): default codex transport to websocket-first 2026-02-26 16:22:53 +01:00
SidQin-cyber
63c6080d50 fix: clean stale gateway PIDs before triggerOpenClawRestart calls launchctl/systemctl
When the /restart command runs inside an embedded agent process (no
SIGUSR1 listener), it falls through to triggerOpenClawRestart() which
calls launchctl kickstart -k directly — bypassing the pre-restart port
cleanup added in #27013. If the gateway was started via TUI/CLI, the
orphaned process still holds the port and the new launchd instance
crash-loops.

Add synchronous stale-PID detection (lsof) and termination
(SIGTERM→SIGKILL) inside triggerOpenClawRestart() itself, so every
caller — including the embedded agent /restart path — gets port cleanup
before the service manager restart command fires.

Closes #26736

Made-with: Cursor
2026-02-26 15:22:35 +00:00
taw0002
792ce7b5b4 fix: detect OpenClaw-managed launchd/systemd services in process respawn
restartGatewayProcessWithFreshPid() checks SUPERVISOR_HINT_ENV_VARS to
decide whether to let the supervisor handle the restart (mode=supervised)
or to fork a detached child (mode=spawned). The existing list only had
native launchd vars (LAUNCH_JOB_LABEL, LAUNCH_JOB_NAME) and systemd vars
(INVOCATION_ID, SYSTEMD_EXEC_PID, JOURNAL_STREAM).

macOS launchd does NOT automatically inject LAUNCH_JOB_LABEL into the
child environment. OpenClaw's own plist generator (buildServiceEnvironment
in service-env.ts) sets OPENCLAW_LAUNCHD_LABEL instead. So on stock macOS
LaunchAgent installs, isLikelySupervisedProcess() returned false, causing
the gateway to fork a detached child on SIGUSR1 restart. The original
process then exits, launchd sees its child died, respawns a new instance
which finds the orphan holding the port — infinite crash loop.

Fix: add OPENCLAW_LAUNCHD_LABEL, OPENCLAW_SYSTEMD_UNIT, and
OPENCLAW_SERVICE_MARKER to the supervisor hint list. These are set by
OpenClaw's own service environment builders for both launchd and systemd
and are the reliable supervised-mode signals.

Fixes #27605
2026-02-26 15:21:23 +00:00
Peter Steinberger
5c0255477c fix: tolerate missing pi-coding-agent backend export 2026-02-26 16:11:37 +01:00
Peter Steinberger
d8477cbb3f fix(ci): sync protocol models and acpx version 2026-02-26 16:10:03 +01:00
Peter Steinberger
fae8de9ae0 fix(browser): land PR #27617 relay reconnect resilience 2026-02-26 15:08:55 +00:00
Peter Steinberger
aa17bdbe4a docs(changelog): reorder all unreleased entries by user impact 2026-02-26 16:05:47 +01:00
Peter Steinberger
45b5c23825 docs(changelog): reorder unreleased changes by user interest 2026-02-26 16:03:29 +01:00
Peter Steinberger
0f9c602591 docs(changelog): highlight external secrets management (#26155) 2026-02-26 16:01:23 +01:00
Peter Steinberger
cc1eaf130b docs(gateway): clarify remote token local fallback semantics 2026-02-26 15:59:44 +01:00
Peter Steinberger
47fc6a0806 fix: stabilize secrets land + docs note (#26155) (thanks @joshavant) 2026-02-26 14:47:22 +00:00
Peter Steinberger
4380d74d49 docs(secrets): add dedicated apply plan contract page 2026-02-26 14:47:22 +00:00
Peter Steinberger
820d614757 fix(secrets): harden plan target paths and ref-only auth profiles 2026-02-26 14:47:22 +00:00
joshavant
485cd0c512 fix(test): skip exec-backed audit batching assertion on windows 2026-02-26 14:47:22 +00:00
joshavant
14897e8de7 docs(secrets): clarify partial migration guidance 2026-02-26 14:47:22 +00:00
joshavant
7671c1dd10 test(secrets): cover skill migration and symlinked exec command flow 2026-02-26 14:47:22 +00:00
joshavant
d879c7c641 fix(secrets): harden apply and audit plan handling 2026-02-26 14:47:22 +00:00
joshavant
ea1ccf4896 docs(secrets): add direct 1password exec example 2026-02-26 14:47:22 +00:00
joshavant
f46b9c996f feat(secrets): allow opt-in symlink exec command paths 2026-02-26 14:47:22 +00:00
joshavant
06290b49b2 feat(secrets): finalize mode rename and validated exec docs 2026-02-26 14:47:22 +00:00
joshavant
ba2eb583c0 fix(secrets): make apply idempotent and keep audit read-only 2026-02-26 14:47:22 +00:00
joshavant
f413e314b9 feat(secrets): replace migrate flow with audit/configure/apply 2026-02-26 14:47:22 +00:00
joshavant
8944b75e16 fix(secrets): align ref contracts and non-interactive ref persistence 2026-02-26 14:47:22 +00:00
joshavant
86622ebea9 fix(secrets): enforce file provider read timeouts 2026-02-26 14:47:22 +00:00
joshavant
67e9554645 test(session): normalize parent fork parentSession path assertion 2026-02-26 14:47:22 +00:00
joshavant
060ede8aaa test(secrets): skip windows ACL-sensitive file-provider runtime tests 2026-02-26 14:47:22 +00:00
joshavant
b84d7796be test(secrets): skip strict file-permission resolver tests on windows 2026-02-26 14:47:22 +00:00
joshavant
bde9cbb058 docs(secrets): align provider model and add exec resolver coverage 2026-02-26 14:47:22 +00:00
joshavant
4e7a833a24 feat(security): add provider-based external secrets management 2026-02-26 14:47:22 +00:00
joshavant
bb60cab76d test: sops invocation assertion
Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-02-26 14:47:22 +00:00
joshavant
5e3a86fd2f feat(secrets): expand onboarding secret-ref flows and custom-provider parity 2026-02-26 14:47:22 +00:00
joshavant
e8637c79b3 fix(secrets): harden sops migration sops rule matching 2026-02-26 14:47:22 +00:00
joshavant
0e69660c41 feat(secrets): finalize external secrets runtime and migration hardening 2026-02-26 14:47:22 +00:00
joshavant
c5b89fbaea Docs: address review feedback on secrets docs 2026-02-26 14:47:22 +00:00
joshavant
9203d583f9 Docs: add secrets and CLI secrets reference pages 2026-02-26 14:47:22 +00:00
joshavant
c0a3801086 Docs: document secrets refs runtime and migration 2026-02-26 14:47:22 +00:00
joshavant
cb119874dc Onboard: require explicit mode for env secret refs 2026-02-26 14:47:22 +00:00
joshavant
4d94b05ac5 Secrets: keep read-only runtime sync in-memory 2026-02-26 14:47:22 +00:00
joshavant
13b4993289 Onboard non-interactive: avoid rewriting profile-backed keys 2026-02-26 14:47:22 +00:00
joshavant
59e5f12bf9 Onboard: move volcengine/byteplus auth from .env to profiles 2026-02-26 14:47:22 +00:00
joshavant
2ef109f00a Onboard OpenAI: explicit secret-input-mode behavior 2026-02-26 14:47:22 +00:00
joshavant
e8d1725187 Onboard auth: remove leftover merge marker 2026-02-26 14:47:22 +00:00
joshavant
fce4d76a78 Tests: narrow OpenAI default model assertion typing 2026-02-26 14:47:22 +00:00
joshavant
68b9d89ee7 Onboard: store OpenAI auth in profiles instead of .env 2026-02-26 14:47:22 +00:00
joshavant
09c7cb5d34 Tests: update onboard credential expectations for explicit ref mode 2026-02-26 14:47:22 +00:00
joshavant
b50d2ce93c Tests: align auth-choice helper expectations with secret mode 2026-02-26 14:47:22 +00:00
joshavant
04aa856fc0 Onboard: require explicit mode for env secret refs 2026-02-26 14:47:22 +00:00
joshavant
103d02f98c Auth choice tests: expect env-backed key refs 2026-02-26 14:47:22 +00:00
joshavant
56f73ae080 Auth choice tests: assert env-backed keyRef persistence 2026-02-26 14:47:22 +00:00
joshavant
58590087de Onboard auth: use shared secret-ref helpers 2026-02-26 14:47:22 +00:00
joshavant
7e1557b8c9 Onboard: persist env-backed API keys as secret refs 2026-02-26 14:47:22 +00:00
joshavant
363334253b Secrets migrate: split plan/apply/backup modules 2026-02-26 14:47:22 +00:00
joshavant
4807e40cbd Agents: restore auth.json static scrub during pi auth discovery 2026-02-26 14:47:22 +00:00
joshavant
8e439e2d81 Secrets migrate: ensure unique backup ids per write 2026-02-26 14:47:22 +00:00
joshavant
a74067d00b Secrets migrate: share helpers and narrow env scrub scope 2026-02-26 14:47:22 +00:00
joshavant
f6a854bd37 Secrets: add migrate rollback and skill ref support 2026-02-26 14:47:22 +00:00
joshavant
2e53033f22 Gateway: serialize secrets activation across reload paths 2026-02-26 14:47:22 +00:00
joshavant
fe56700026 Gateway: add manual secrets reload command 2026-02-26 14:47:22 +00:00
joshavant
301fe18909 Agents: inject pi auth storage from runtime profiles 2026-02-26 14:47:22 +00:00
joshavant
6a251d8d74 Auth profiles: resolve keyRef/tokenRef outside gateway 2026-02-26 14:47:22 +00:00
joshavant
5ae367aadd Tests: stub discoverAuthStorage in model catalog mocks 2026-02-26 14:47:22 +00:00
joshavant
cec404225d Auth labels: handle token refs and share Pi credential conversion 2026-02-26 14:47:22 +00:00
joshavant
e1301c31e7 Auth profiles: never persist plaintext when refs are present 2026-02-26 14:47:22 +00:00
joshavant
4c5a2c3c6d Agents: inject pi auth storage from runtime profiles 2026-02-26 14:47:22 +00:00
joshavant
45ec5aaf2b Secrets: keep read-only runtime sync in-memory 2026-02-26 14:47:22 +00:00
joshavant
8e33ebe471 Secrets: make runtime activation auth loads read-only 2026-02-26 14:47:22 +00:00
joshavant
3dbb6be270 Gateway tests: handle async restart callback path 2026-02-26 14:47:22 +00:00
joshavant
1560f02561 Gateway: mark restart callback promise as intentionally detached 2026-02-26 14:47:22 +00:00
joshavant
eb855f75ce Gateway: emit one-shot operator events for secrets degraded/recovered 2026-02-26 14:47:22 +00:00
joshavant
e45729a430 Secrets runtime: include sourceConfig in prepared snapshot type 2026-02-26 14:47:22 +00:00
joshavant
e4915cb107 Secrets: preserve runtime snapshot source refs on write 2026-02-26 14:47:22 +00:00
joshavant
b1533bc80c Gateway: avoid double secrets activation at startup 2026-02-26 14:47:22 +00:00
joshavant
b50c4c2c44 Gateway: add eager secrets runtime snapshot activation 2026-02-26 14:47:22 +00:00
joshavant
2f3b919b94 Config: remove unused extension path helper 2026-02-26 14:47:22 +00:00
joshavant
d00ed73026 Config: enforce source-specific SecretRef id validation 2026-02-26 14:47:22 +00:00
joshavant
c3a4251a60 Config: add secret ref schema and redaction foundations 2026-02-26 14:47:22 +00:00
Vincent Koc
6daf40d3f4 Gemini OAuth: resolve npm global shim install layouts (#27585)
* Changelog: credit session path fixes

* test(gemini-oauth): cover npm global shim credential discovery

* fix(gemini-oauth): resolve npm global shim install roots
2026-02-26 09:43:05 -05:00
Peter Steinberger
79659b2b14 fix(browser): land PR #11880 decodeURIComponent guardrails
Guard malformed percent-encoding in relay target routes and browser dispatcher params, add regression tests, and update changelog.
Landed from contributor @Yida-Dev (PR #11880).

Co-authored-by: Yida-Dev <reyifeijun@gmail.com>
2026-02-26 14:37:48 +00:00
Harold Hunt
62a248eb99 core(protocol): pnpm protocol:check 2026-02-26 20:03:25 +05:30
Ayaan Zaidi
22b0f36350 fix: add changelog entry for telegram webhook updates (#25732) (thanks @huntharo) 2026-02-26 20:01:50 +05:30
Harold Hunt
dbfdf60a42 fix(telegram): Allow ephemeral webhookPort 2026-02-26 20:01:50 +05:30
Harold Hunt
296210636d fix(telegram): Log bound port if ephemeral (0) is configured 2026-02-26 20:01:50 +05:30
Harold Hunt
840b768d97 Telegram: improve webhook config guidance and startup fallback 2026-02-26 20:01:50 +05:30
Peter Steinberger
5416cabdf8 fix(browser): land PR #21277 dedupe concurrent relay init
Add shared per-port relay initialization dedupe so concurrent callers await a single startup lifecycle, with regression coverage and changelog entry.
Landed from contributor @HOYALIM (PR #21277).

Co-authored-by: Ho Lim <subhoya@gmail.com>
2026-02-26 14:30:46 +00:00
Peter Steinberger
65d5a91242 fix(browser): land PR #22571 with safe extension handshake handling
Bind relay WS message handling before onopen and add non-blocking connect.challenge response support without forcing handshake waits on current relay protocol.
Landed from contributor @pandego (PR #22571).

Co-authored-by: pandego <7780875+pandego@users.noreply.github.com>
2026-02-26 14:26:14 +00:00
Peter Steinberger
ce833cd6de fix(browser): land PR #24142 flush relay pending timers on stop
Flush pending extension request timers/rejections during relay shutdown and document in changelog.
Landed from contributor @kevinWangSheng (PR #24142).

Co-authored-by: Shawn <118158941+kevinWangSheng@users.noreply.github.com>
2026-02-26 14:20:43 +00:00
Peter Steinberger
42cf32c386 fix(browser): land PR #26015 query-token auth for /json relay routes
Align relay HTTP /json auth with websocket auth by accepting query-param tokens, add regression coverage, and update changelog.
Landed from contributor @Sid-Qin (PR #26015).

Co-authored-by: SidQin-cyber <sidqin0410@gmail.com>
2026-02-26 14:17:41 +00:00
张哲芳
77a3930b72 fix(gateway): allow cron commands to use gateway.remote.token (#27286)
* fix(gateway): allow cron commands to use gateway.remote.token

* fix(gateway): make local remote-token fallback effective

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-26 14:17:30 +00:00
Peter Steinberger
4c75eca580 fix(browser): land PR #23962 extension relay CORS fix
Reworks browser relay CORS handling for extension-origin preflight and JSON responses, adds regression tests, and updates changelog.
Landed from contributor @miloudbelarebia (PR #23962).

Co-authored-by: Miloud Belarebia <miloudbelarebia@users.noreply.github.com>
2026-02-26 14:14:30 +00:00
Peter Steinberger
081b1aa1ed refactor(gateway): unify v3 auth payload builders and vectors 2026-02-26 15:08:50 +01:00
Peter Steinberger
8315c58675 refactor(auth-profiles): unify coercion and add rejected-entry diagnostics 2026-02-26 14:42:11 +01:00
Peter Steinberger
96aad965ab fix: land NO_REPLY announce suppression and auth scope assertions
Landed follow-up for #27535 and aligned shared-auth gateway expectations after #27498.

Co-authored-by: kevinWangSheng <118158941+kevinWangSheng@users.noreply.github.com>
2026-02-26 13:40:58 +00:00
SidQin-cyber
eb9a968336 fix(slack): suppress NO_REPLY before Slack API call
Guard sendMessageSlack against NO_REPLY tokens reaching the Slack API,
which caused truncated push notifications before the reply filter could
intercept them.

Made-with: Cursor
(cherry picked from commit fab9b52039)
2026-02-26 13:40:58 +00:00
Kevin Shenghui
9c142993b8 fix: preserve operator scopes for shared auth connections
When connecting via shared gateway token (no device identity),
the operator scopes were being cleared, causing API operations
to fail with 'missing scope' errors.

This fix preserves scopes when sharedAuthOk is true, allowing
headless/API operator clients to retain their requested scopes.

Fixes #27494

(cherry picked from commit c71c8948bd)
2026-02-26 13:40:58 +00:00
Ubuntu
0ab5f4c43b fix: enable store=true for Azure OpenAI Responses API
Azure OpenAI endpoints were not recognized by shouldForceResponsesStore(),
causing store=false to be sent with all Azure Responses API requests.
This broke multi-turn conversations because previous_response_id referenced
responses that Azure never stored.

Add "azure-openai-responses" to the provider whitelist and
*.openai.azure.com to the URL check in isDirectOpenAIBaseUrl().

Fixes #27497

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 185f3814e9)
2026-02-26 13:40:58 +00:00
SidQin-cyber
71e45ceecc fix(sessions): add fix-missing cleanup path for orphaned store entries
Introduce a sessions cleanup flag to prune entries whose transcript files are missing and surface the exact remediation command from doctor to resolve missing-transcript deadlocks.

Made-with: Cursor
(cherry picked from commit 690d3d596b)
2026-02-26 13:40:58 +00:00
SidQin-cyber
a481ed00f5 fix(config): warn and ignore unknown plugin entry keys
Prevent gateway startup failures when plugins.entries contains stale or removed plugin ids by downgrading unknown entry keys from validation errors to warnings.

Made-with: Cursor
(cherry picked from commit 34ef28cf63)
2026-02-26 13:40:58 +00:00
SidQin-cyber
1ba525f94d fix(telegram): degrade command sync on BOT_COMMANDS_TOO_MUCH
When Telegram rejects native command registration for excessive commands, progressively retry with fewer commands instead of hard-failing startup.

Made-with: Cursor
(cherry picked from commit a02c40483e)
2026-02-26 13:40:58 +00:00
SidQin-cyber
79176cc4e5 fix(typing): force cleanup when dispatch idle is never received
Add a grace timer after markRunComplete so the typing controller
cleans up even when markDispatchIdle is never called, preventing
indefinite typing keepalive loops in cron and announce flows.

Made-with: Cursor
(cherry picked from commit 684eaf2893)
2026-02-26 13:40:58 +00:00
Peter Steinberger
4b259ab81b fix(models): normalize trailing @profile parsing across resolver paths
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Marcus Castro <mcaxtr@gmail.com>
Co-authored-by: Brandon Wise <brandonawise@gmail.com>
2026-02-26 14:34:15 +01:00
Peter Steinberger
00e8e88a7c docs(changelog): note auth-profile alias normalization (#26950) (thanks @byungsker) 2026-02-26 13:32:05 +00:00
lbo728
7e7ca43a79 fix(auth-profiles): accept mode/apiKey aliases to prevent silent credential loss
Users following openclaw.json auth.profiles examples (which use 'mode' for
the credential type) would write their auth-profiles.json entries with:
  { provider: "anthropic", mode: "api_key", apiKey: "sk-ant-..." }

The actual auth-profiles.json schema uses:
  { provider: "anthropic", type: "api_key", key: "sk-ant-..." }

coerceAuthStore() and coerceLegacyStore() validated entries strictly on
typed.type, silently skipping any entry that used the mode/apiKey spelling.
The user would get 'No API key found for provider anthropic' with no hint
about the field name mismatch.

Add normalizeRawCredentialEntry() which, before validation:
- coerces mode → type when type is absent
- coerces apiKey → key when key is absent

Both functions now call the normalizer before the type guard so
mode/apiKey entries are loaded and resolved correctly.

Fixes #26916
2026-02-26 13:32:05 +00:00
Nimrod Gutman
85b075d0cc fix: record ios talk voice directive hint removal (#27543) (thanks @ngutman) 2026-02-26 15:19:07 +02:00
Nimrod Gutman
185c393459 fix(ios): remove talk voice directive hint 2026-02-26 15:19:07 +02:00
Peter Steinberger
490cb5174d fix(apps): sign gateway device auth with v3 payload 2026-02-26 14:16:49 +01:00
Peter Steinberger
473a27470f fix(auto-reply): gate inline directives on resolved auth (#27248)
Landed from contributor PR #27248 by @kevinWangSheng.

Co-authored-by: shenghui kevin <shenghuikevin@shenghuideMac-mini.local>
2026-02-26 13:11:39 +00:00
Peter Steinberger
7d8aeaaf06 fix(gateway): pin paired reconnect metadata for node policy 2026-02-26 14:11:04 +01:00
Vincent Koc
cf311978ea fix(plugins): fallback bundled channel specs when npm install returns 404 (#12849)
* plugins: add bundled source resolver

* plugins: add bundled source resolver tests

* cli: fallback npm 404 plugin installs to bundled sources

* plugins: use bundled source resolver during updates

* protocol: regenerate macos gateway swift models

* protocol: regenerate shared swift models

* Revert "protocol: regenerate shared swift models"

This reverts commit 6a2b08c47d.

* Revert "protocol: regenerate macos gateway swift models"

This reverts commit 27c03010c6.
2026-02-26 08:06:54 -05:00
Peter Steinberger
7b5153f214 refactor: dedupe boundary-path canonical checks 2026-02-26 14:04:47 +01:00
Peter Steinberger
b402770f63 refactor(reply): split abort cutoff and timeout policy modules 2026-02-26 14:00:35 +01:00
Harold Hunt
f53e4e9ffb chore: Fix broken build protocol:check 2026-02-26 18:22:38 +05:30
Peter Steinberger
c397a02c9a fix(queue): harden drain/abort/timeout race handling
- reject new lane enqueues once gateway drain begins
- always reset lane draining state and isolate onWait callback failures
- persist per-session abort cutoff and skip stale queued messages
- avoid false 600s agentTurn timeout in isolated cron jobs

Fixes #27407
Fixes #27332
Fixes #27427

Co-authored-by: Kevin Shenghui <shenghuikevin@github.com>
Co-authored-by: zjmy <zhangjunmengyang@gmail.com>
Co-authored-by: suko <miha.sukic@gmail.com>
2026-02-26 13:43:39 +01:00
Peter Steinberger
1aef45bc06 fix: harden boundary-path canonical alias handling 2026-02-26 13:43:29 +01:00
Peter Steinberger
4b71de384c fix(core): unify session-key normalization and plugin boundary checks 2026-02-26 12:41:23 +00:00
Peter Steinberger
e3385a6578 fix(security): harden root file guards and host writes 2026-02-26 13:32:58 +01:00
Peter Steinberger
2ca2d5ab1c docs: add changelog note for sandbox alias fix 2026-02-26 13:30:45 +01:00
Peter Steinberger
4fd29a35bb fix: block broken-symlink sandbox path escapes 2026-02-26 13:30:45 +01:00
Peter Steinberger
8b5ebff67b fix(cron): prevent isolated hook session-key double-prefixing (land #27333, @MaheshBhushan)
Co-authored-by: MaheshBhushan <mkoduri73@gmail.com>
2026-02-26 12:29:10 +00:00
Matt Hulme
f692288301 feat(cron): add --session-key option to cron add/edit CLI commands
Expose the existing CronJob.sessionKey field through the CLI so users
can target cron jobs at specific named sessions without needing an
external shell script + system crontab workaround.

The backend already fully supports sessionKey on cron jobs - this
change wires it to the CLI surface with --session-key on cron add,
and --session-key / --clear-session-key on cron edit.

Closes #27158

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:28:49 +00:00
Ayaan Zaidi
452a8c9db9 fix: use canonical cron session detection for spawn note 2026-02-26 17:54:27 +05:30
Taras Lukavyi
69590de276 fix: suppress SUBAGENT_SPAWN_ACCEPTED_NOTE for cron isolated sessions
The 'do not poll/sleep' note added to sessions_spawn tool results causes
cron isolated agents to immediately end their turn, since the note tells
them not to wait for subagent results. In cron isolated sessions, the
agent turn IS the entire run, so ending early means subagent results
are never collected.

Fix: detect cron sessions via includes(':cron:') in agentSessionKey
and suppress the note, allowing the agent to poll/wait naturally.

Note: PR #27330 used startsWith('cron:') which never matches because
the session key format is 'agent:main:cron:...' (starts with 'agent:').

Fixes #27308
Fixes #25069
2026-02-26 17:54:27 +05:30
Peter Steinberger
46eba86b45 fix: harden workspace boundary path resolution 2026-02-26 13:19:59 +01:00
Peter Steinberger
ecb2053fdd chore(pr): guard against dropped changelog refs 2026-02-26 13:19:25 +01:00
Peter Steinberger
125dc322f5 refactor(feishu): unify account-aware tool routing and message body 2026-02-26 13:19:25 +01:00
Peter Steinberger
5df9aacf68 fix(podman): default run-openclaw-podman bind to loopback (land #27491, thanks @robbyczgw-cla)
Co-authored-by: robbyczgw-cla <robbyczgw@gmail.com>
2026-02-26 12:13:20 +00:00
Peter Steinberger
a288f3066f fix(gateway): warn on non-loopback bind at startup (land #25397, thanks @let5sne)
Co-authored-by: let5sne <let5sne@users.noreply.github.com>
2026-02-26 12:13:20 +00:00
Peter Steinberger
327f0526d1 fix(gateway): use loopback for CLI status probe when bind=lan (land #26997, thanks @chikko80)
Co-authored-by: Manuel Seitz <seitzmanuel0@gmail.com>
2026-02-26 12:13:20 +00:00
Peter Steinberger
da53015ef5 fix(onboard): seed Control UI origins for non-loopback binds (land #26157, thanks @stakeswky)
Co-authored-by: 不做了睡大觉 <stakeswky@users.noreply.github.com>
2026-02-26 12:13:20 +00:00
Peter Steinberger
a97cec0018 refactor: harden remaining plugin manifest reads 2026-02-26 13:12:44 +01:00
Peter Steinberger
892a9c24b0 refactor(security): centralize channel allowlist auth policy 2026-02-26 13:06:33 +01:00
Peter Steinberger
eac86c2081 refactor: unify boundary hardening for file reads 2026-02-26 13:04:37 +01:00
Peter Steinberger
cf4853e2b8 fix: avoid duplicate feishu permission-error dispatch replies (#27381) (thanks @byungsker) 2026-02-26 12:03:41 +00:00
lbo728
736ec9690f fix(feishu): merge permission error notice into main dispatch instead of separate agent turn
When the sender-name lookup fails with a Feishu permission error (code
99991672), the bot was dispatching two separate agent turns:

  1. A dedicated permission-error notification turn
  2. The regular inbound user message turn

This caused two bot replies for a single user message, degrading UX and
wasting tokens.

Fix: instead of a separate dispatch, append the permission error notice
directly to the main messageBody. The agent receives both the user's
message and the system notice in a single turn, and responds once.

Fixes #27372
2026-02-26 12:03:41 +00:00
Peter Steinberger
d671d7a0a2 fix: preserve feishu message_id in agent-visible body (#27253) (thanks @xss925175263) 2026-02-26 12:02:00 +00:00
xianshishan
6d52b47076 feishu: send message_id in BodyForAgent (fix #27218) 2026-02-26 12:02:00 +00:00
咸士山 0668001391
db6c513d1e feishu: include message_id in agent message body (fix #27218) 2026-02-26 12:02:00 +00:00
Peter Steinberger
6632fd1ea9 refactor(security): extract protected-route path policy helpers 2026-02-26 13:01:22 +01:00
Peter Steinberger
39b5ffdaa6 fix: route feishu doc tools by agent account context (#27338) (thanks @AaronL725) 2026-02-26 12:00:45 +00:00
root
58c100f66f fix(feishu): remove hook registration, fix docx getClient call 2026-02-26 12:00:45 +00:00
root
10d9549764 fix(feishu): fix hook types and docx client call 2026-02-26 12:00:45 +00:00
root
151ee6014a fix(feishu): route doc tools by agent account
Previously feishu_doc always used accounts[0], so multi-account setups created docs under the first bot regardless of the calling agent.

This change resolves accountId via a before_tool_call hook (defaulting from agentAccountId) and selects the Feishu client per call.

Fixes #27321
2026-02-26 12:00:45 +00:00
Peter Steinberger
8bdda7a651 fix(security): keep DM pairing allowlists out of group auth 2026-02-26 12:58:18 +01:00
echoVic
d08dafb08f fix(feishu): bitable tools use listEnabledFeishuAccounts for multi-account mode (#27244)
The bitable tool registration was reading credentials directly from
top-level feishuCfg.appId/appSecret, missing the accounts.* path used
in multi-account mode. Align with drive.ts and wiki.ts by using
listEnabledFeishuAccounts() which handles both legacy and multi-account
configurations.
2026-02-26 11:56:18 +00:00
Peter Steinberger
0ed675b1df fix(security): harden canonical auth matching for plugin channel routes 2026-02-26 12:55:33 +01:00
Peter Steinberger
0231cac957 feat(typing): add TTL safety-net for stuck indicators (land #27428, thanks @Crpdim)
Co-authored-by: Crpdim <crpdim@users.noreply.github.com>
2026-02-26 11:48:50 +00:00
Peter Steinberger
3d30ba18a2 fix(slack): gate member and message subtype system events 2026-02-26 12:48:20 +01:00
Peter Steinberger
da0ba1b73a fix(security): harden channel auth path checks and exec approval routing 2026-02-26 12:46:05 +01:00
Peter Steinberger
b096ad267e fix(telegram): add sendChatAction 401 backoff guard (land #27415, thanks @widingmarcus-cyber)
Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
2026-02-26 11:45:57 +00:00
Peter Steinberger
b74be2577f refactor(web): unify proxy-guarded fetch path for web tools 2026-02-26 12:44:18 +01:00
Peter Steinberger
8bf1c9a23a fix(typing): stop keepalive restarts after run completion (land #27413, thanks @widingmarcus-cyber)
Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
2026-02-26 11:42:38 +00:00
Peter Steinberger
fec3fdf7ef test(msteams): align silent-prefix expectation with exact NO_REPLY semantics 2026-02-26 11:42:38 +00:00
Peter Steinberger
242188b7b1 refactor: unify boundary-safe reads for bootstrap and includes 2026-02-26 12:42:14 +01:00
Peter Steinberger
199ef9f8ea fix(typing): add main-run dispatch idle safety net (land #27250, thanks @Sid-Qin)
Co-authored-by: Sid Qin <s3734389@gmail.com>
2026-02-26 11:36:08 +00:00
Peter Steinberger
46003e85bf fix: unify web tool proxy path (#27430) (thanks @kevinWangSheng) 2026-02-26 11:32:43 +00:00
Kevin Shenghui
d8e2030d47 fix(web-search): honor HTTP_PROXY environment variable for Brave Search API
The web_search tool was not respecting HTTP_PROXY/HTTPS_PROXY environment
variables, causing 'fetch failed' errors when running behind a proxy.

This fix adds ProxyAgent support for the Brave Search API, similar to how
other tools in OpenClaw handle proxy configuration.

Fixes #27405
2026-02-26 11:32:43 +00:00
Peter Steinberger
9925ac6a2d fix(config): harden include file loading path checks 2026-02-26 12:23:31 +01:00
Peter Steinberger
caace61ba1 chore: bump versions to 2026.2.26 2026-02-26 12:11:02 +01:00
Ayaan Zaidi
e7b600e318 chore(acpx): bump package version to 2026.2.25 2026-02-26 16:29:12 +05:30
Ayaan Zaidi
4f869b2b76 docs(changelog): thank @emanuelst for telegram preview fix (#27449) 2026-02-26 16:24:31 +05:30
Ayaan Zaidi
d9ed2c425a fix(telegram): prime final preview before stop flush 2026-02-26 16:24:31 +05:30
Gustavo Madeira Santana
e273b9851e Tests: tighten discord work account type in doctor config flow 2026-02-26 05:38:53 -05:00
Gustavo Madeira Santana
1ffc319831 Doctor: keep allowFrom account-scoped in multi-account configs 2026-02-26 05:34:58 -05:00
Ayaan Zaidi
97fa44dc82 fix: changelog for NO_REPLY streaming fix (#19576) (thanks @aldoeliacim) 2026-02-26 16:04:48 +05:30
Ayaan Zaidi
133f14c0af docs(auto-reply): align silent token comment with regex 2026-02-26 16:04:48 +05:30
Ayaan Zaidi
e64d72299e fix(auto-reply): tighten silent token semantics and prefix streaming 2026-02-26 16:04:48 +05:30
HAL
2f2110a32c fix: tighten isSilentReplyText to match whole-text only
The suffix regex matched NO_REPLY at the end of any response,
suppressing substantive replies when models (e.g. Gemini 3 Pro)
appended NO_REPLY to real content.

Replace prefix+suffix regexes with a single whole-string match.
Only responses that are entirely the silent token (with optional
whitespace) are now suppressed.

Add unit tests for the fix.

Fixes #19537
2026-02-26 16:04:48 +05:30
Onur Solmaz
a7d56e3554 feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
2026-02-26 11:00:09 +01:00
Gustavo Madeira Santana
a9d9a968ed chore(changelog): move post release entries to unreleased section 2026-02-26 04:59:54 -05:00
Gustavo Madeira Santana
a690b62391 Doctor: ignore slash sessions in transcript integrity check
Merged via deterministic merge flow.

Prepared head SHA: e5cee7a2ec

Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
2026-02-26 04:35:08 -05:00
Ayaan Zaidi
30fd2bbe19 fix(ssrf): honor global family policy for pinned dispatcher 2026-02-26 14:57:15 +05:30
Ayaan Zaidi
0e3ed28950 fix: changelog for telegram group inline callbacks (#27343) (thanks @GodsBoy) 2026-02-26 14:43:11 +05:30
GodsBoy
58fef1d703 fix(telegram): allow inline button callbacks in groups when command was authorized (#27309) 2026-02-26 14:43:11 +05:30
Gustavo Madeira Santana
dfa0b5b4fc Channels: move single-account config into accounts.default (#27334)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 50b5771808
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 04:06:03 -05:00
Ayaan Zaidi
da6a96ed33 fix: update changelog for notifications list land (#27344) (thanks @obviyus) 2026-02-26 14:33:14 +05:30
Ayaan Zaidi
a0cf753b2e refactor(agents): dedupe node read invoke commands 2026-02-26 14:33:14 +05:30
Ayaan Zaidi
05817187fe refactor(android): unify notifications.list status flow 2026-02-26 14:33:14 +05:30
Ayaan Zaidi
c0073b3d47 feat(agents): add nodes notifications_list action 2026-02-26 14:33:14 +05:30
Ayaan Zaidi
e6a5d5784c feat(gateway): allow notifications.list for android nodes 2026-02-26 14:33:14 +05:30
Ayaan Zaidi
cf4fe41957 feat(android): add notifications.list node command 2026-02-26 14:33:14 +05:30
Sid
c289b5ff9f fix(config): preserve agent-level apiKey/baseUrl during models.json merge (#27293)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 6b4b37b03d
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 03:46:36 -05:00
yinghaosang
92c309f2e1 docs: fix wrong Providers link in configuration examples 2026-02-26 02:41:07 -06:00
Gustavo Madeira Santana
39d725f4d3 Daemon tests: guard undefined runtime status 2026-02-26 03:24:48 -05:00
Gustavo Madeira Santana
4ebefe647a fix(daemon): keep launchd KeepAlive while preserving restart hardening 2026-02-26 02:52:00 -05:00
Frank Yang
b975711429 fix(daemon): stabilize LaunchAgent restart and proxy env passthrough (#27276)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b08797a995
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 02:40:48 -05:00
Gustavo Madeira Santana
96c7702526 Agents: add account-scoped bind and routing commands (#27195)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad35a458a5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 02:36:56 -05:00
Ayaan Zaidi
c5d040bbea fix: update changelog for android invoke distill (#27257) (thanks @obviyus) 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
ac6539ed03 refactor(android): unify invoke availability gating 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
a87d961ebc fix(android): require gateway device auth store 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
f7865527af fix(android): omit websocket Origin for native gateway connect 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
c3f54fcddd refactor(android): unify invoke error parsing 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
39d362aeff refactor(android): distill invoke dispatcher command flow 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
18fc4c113b refactor(android): centralize invoke command registry 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
d4ae8a8d34 test(android): cover invoke paramsJSON and error mapping 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
8117a13dd6 fix(nodes): default camera snap to front high-quality image 2026-02-26 12:17:32 +05:30
Ayaan Zaidi
bee0c564cf test(android): add GatewaySession invoke roundtrip test 2026-02-26 12:17:32 +05:30
Josh Avant
72adf2458b CI: shard Windows test lane for faster CI critical path (#27234)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f7c41089e0
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Reviewed-by: @joshavant
2026-02-26 00:33:36 -06:00
Gustavo Madeira Santana
f08fe02a1b Onboarding: support plugin-owned interactive channel flows (#27191)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 53872cf8e7
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 01:14:57 -05:00
Gustavo Madeira Santana
39a1c13635 chore(ci): fix cross-platform symlink path assertions in agents file tests 2026-02-26 00:39:18 -05:00
Gustavo Madeira Santana
91a3f0a3fe pairing: enforce strict account-scoped state 2026-02-26 00:31:24 -05:00
Gustavo Madeira Santana
d9b19e5970 plugin-sdk: export shared timezone formatting helpers (#27196) 2026-02-26 00:00:00 -05:00
Gustavo Madeira Santana
cf8d01bc5a pairing: isolate account-scoped allowlist and pending requests 2026-02-25 23:48:43 -05:00
Peter Steinberger
35976da7a0 fix: harden Docker/GCP onboarding flow (#26253) (thanks @pandego) 2026-02-26 04:46:18 +00:00
pandego
e8197404d0 Docker/docs: reduce docker build OOM risk on small GCP hosts 2026-02-26 04:46:18 +00:00
Peter Steinberger
cb3e5c35b0 docs: fix onboarding markdown list spacing 2026-02-26 05:23:30 +01:00
Peter Steinberger
4b5d4a4c66 docs: finalize 2026.2.25 release notes and appcast 2026-02-26 05:15:27 +01:00
Peter Steinberger
04870a5528 test(session): make fork parent path assertion cross-platform 2026-02-26 05:12:51 +01:00
Ayaan Zaidi
7493f11b40 fix(ci): allow legacy patch tags to publish docker latest 2026-02-26 09:38:13 +05:30
Ayaan Zaidi
41314c691d fix(ci): gate docker latest tag to stable release format 2026-02-26 09:38:13 +05:30
Ayaan Zaidi
bf70614943 fix(ci): publish latest tag for stable docker release 2026-02-26 09:38:13 +05:30
Ayaan Zaidi
3b0298562b fix: document telegram group allowlist hardening (#25988) (thanks @bmendonca3) 2026-02-26 09:21:54 +05:30
Ayaan Zaidi
470c606dac refactor(telegram): remove dmPolicy from group allow context helper 2026-02-26 09:21:54 +05:30
bmendonca3
c7352f6b3f security(telegram): fail closed group allowlist against DM pairing store 2026-02-26 09:21:54 +05:30
Peter Steinberger
5500000492 chore(protocol): regenerate Swift gateway models 2026-02-26 04:43:27 +01:00
Peter Steinberger
fdea7415cc docs: reorder unreleased changelog by user impact 2026-02-26 04:39:01 +01:00
Peter Steinberger
e915b4c64a refactor: unify monitor abort lifecycle handling 2026-02-26 04:36:25 +01:00
Peter Steinberger
02c731826a test(discord): fix monitor test typings 2026-02-26 04:35:49 +01:00
Peter Steinberger
e35fe7888b refactor: centralize message-provider tool filtering 2026-02-26 04:22:49 +01:00
Theo Tarr
7af6849c2f Discord: handle early gateway startup errors 2026-02-26 03:22:02 +00:00
Peter Steinberger
9b81a53016 fix: add changelog note for LINE lifecycle fix (#26528) (thanks @Sid-Qin) 2026-02-26 03:20:57 +00:00
SidQin-cyber
243e28df4f fix(line): keep startAccount pending until abort signal to prevent restart loop
monitorLineProvider() registers the webhook HTTP route and returns
immediately.  Because startAccount() directly returned that resolved
promise, the channel supervisor interpreted it as "provider exited"
and triggered auto-restart up to 10 times.

Await a promise gated on ctx.abortSignal so startAccount stays alive
for the full provider lifecycle, matching the contract expected by the
channel supervisor.

Closes #26478

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-26 03:20:57 +00:00
Hongwei Ma
f55238e72a chore: remove accidental PR_STATUS.md from repo
This file appears to be a personal agent tracking document that was
accidentally committed to the main repository. It contains internal
PR submission plans and CI status tracking that doesn't belong in
the upstream codebase.
2026-02-26 08:49:00 +05:30
Peter Steinberger
e4d62c21be test: expand voice provider tts regression coverage 2026-02-26 04:15:11 +01:00
Peter Steinberger
f789f880c9 fix(security): harden approval-bound node exec cwd handling 2026-02-26 04:14:11 +01:00
Peter Steinberger
8f8e2b13b4 fix: disable tts tool for voice provider 2026-02-26 04:12:39 +01:00
Peter Steinberger
8a97803474 fix(agents): normalize malformed tool results in adapter (#27007) 2026-02-26 04:11:44 +01:00
Peter Steinberger
b37dc42240 fix(cron): suppress fallback summary after attempted announce delivery 2026-02-26 03:09:14 +00:00
Peter Steinberger
e16e8f5af2 refactor(slack): share system-event ingress and test harness 2026-02-26 04:01:33 +01:00
Peter Steinberger
4ada143794 docs(heartbeat): add directPolicy to config examples 2026-02-26 03:59:38 +01:00
Peter Steinberger
de61e9c977 refactor(security): unify path alias guard policies 2026-02-26 03:59:17 +01:00
Peter Steinberger
8a006a3260 feat(heartbeat): add directPolicy and restore default direct delivery 2026-02-26 03:57:03 +01:00
Harold Hunt
ee594e2fdb fix(telegram): webhook hang - tests and fix (openclaw#26933) thanks @huntharo
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-25 20:56:53 -06:00
Peter Steinberger
1e7ec8bfd2 fix(routing): preserve explicit cron account and bound message defaults
Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com>
Co-authored-by: stakeswky <64798754+stakeswky@users.noreply.github.com>
2026-02-26 02:56:03 +00:00
Peter Steinberger
92eb3dfc9d refactor(security): unify exec approval request matching 2026-02-26 03:54:37 +01:00
Peter Steinberger
75dfb71e4e fix(slack): gate pin/reaction system events by sender auth 2026-02-26 03:48:58 +01:00
Peter Steinberger
61b3246a7f fix(ssrf): unify ipv6 special-use blocking 2026-02-26 03:43:42 +01:00
Peter Steinberger
04d91d0319 fix(security): block workspace hardlink alias escapes 2026-02-26 03:42:54 +01:00
Peter Steinberger
53fcfdf794 fix(telegram): preserve finalized previews on mixed text+voice turns 2026-02-26 03:42:47 +01:00
Peter Steinberger
03e689fc89 fix(security): bind system.run approvals to argv identity 2026-02-26 03:41:31 +01:00
Peter Steinberger
baf656bc6f fix: block IPv6 multicast SSRF bypass 2026-02-26 03:35:10 +01:00
Ayaan Zaidi
260bec5985 fix: add changelog for chat compose mobile layout (#11167) (thanks @junyiz) 2026-02-26 08:03:57 +05:30
Junyi
7b4fe6d9bc style(chat): UI: add mobile layout for chat compose actions
- Stack chat compose row vertically on mobile (max-width: 640px)
- Change action buttons to vertical layout with full width
- Improve mobile UX for send and session control buttons
2026-02-26 08:03:57 +05:30
Peter Steinberger
b786d11fea refactor(telegram): simplify polling restart flow 2026-02-26 03:33:20 +01:00
Peter Steinberger
069bbf9741 fix(slack): land #26878 allowlist channel ID case-insensitive match (thanks @lbo728)
Land contributor PR #26878 from @lbo728; include changelog credit and regression tests.

Co-authored-by: lbo728 <extreme0728@gmail.com>
2026-02-26 02:21:02 +00:00
Ayaan Zaidi
958cafc54f fix: add changelog note for android startup perf (#26659) (thanks @obviyus) 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
410ba918fb fix(android): hydrate gateway token state on init 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
3175640ea2 docs(android): add perf CLI workflow docs 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
b49c2cbdd9 perf(android): tighten startup path and add perf tooling 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
4a07c89816 perf(android): make gateway token writes async 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
8d68199793 perf(android): cache device identity and speed hex encoding 2026-02-26 07:50:09 +05:30
Ayaan Zaidi
00fc1f56f1 perf(android): remove startup bc provider registration 2026-02-26 07:50:09 +05:30
Peter Steinberger
b8bb8ab3ca docs: clarify personal-by-default onboarding security notice 2026-02-26 02:59:34 +01:00
Peter Steinberger
347f7b9550 fix(msteams): bind file consent invokes to conversation 2026-02-26 02:49:50 +01:00
Peter Steinberger
1f004e6640 refactor(tmp): simplify trusted tmp dir state checks 2026-02-26 02:46:53 +01:00
Ramez
acbb93be48 fix(agents): comprehensive quota fallback fixes - session overrides + surgical cooldown logic (#23816)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: e6f2b4742b
Co-authored-by: ramezgaberiel <844893+ramezgaberiel@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-25 20:35:40 -05:00
Peter Steinberger
0cc3e8137c refactor(gateway): centralize trusted-proxy control-ui bypass policy 2026-02-26 02:26:52 +01:00
sten moocow
95c6b3a912 fix(telegram): recover polling after prolonged network outages
When grammY's runner exceeds maxRetryTime during a network outage,
runner.task() resolves cleanly. Previously, the polling loop treated
this as an intentional stop and exited permanently — killing Telegram
polling for the lifetime of the gateway process.

Now the outer loop detects this case and restarts with exponential
backoff, so polling recovers once connectivity is restored.

Also bumps maxRetryTime from 5 minutes to 60 minutes so the runner
itself survives longer outages (e.g. scheduled internet downtime)
without needing the outer loop restart path.
2026-02-26 01:25:02 +00:00
Peter Steinberger
ce8c67c314 fix(slack): gate interactive system events by sender auth 2026-02-26 02:11:50 +01:00
Peter Steinberger
5e1bfb2ce2 docs(changelog): add followup typing fix note (#26881) 2026-02-26 01:07:32 +00:00
Peter Steinberger
8c701ba1ff test(gateway): add hooks bind-host hardening coverage 2026-02-26 00:54:39 +00:00
Peter Steinberger
3cd3d489f4 docs(changelog): note trusted-proxy control-ui hardening 2026-02-26 01:54:32 +01:00
Peter Steinberger
ec45c317f5 fix(gateway): block trusted-proxy control-ui node bypass 2026-02-26 01:54:19 +01:00
codexGW
6fb082e131 fix(typing): call markDispatchIdle in followup runner to prevent stuck indicator (#26881)
The followup runner (used for queued messages, inter-agent sends,
heartbeat followups, etc.) only called typing.markRunComplete() in
its finally block.  The typing controller requires BOTH markRunComplete
AND markDispatchIdle to trigger cleanup — but markDispatchIdle was
only wired through the buffered dispatcher path, which followup turns
bypass entirely.

This caused the typing indicator to persist indefinitely on channels
like Telegram when the agent replied with NO_REPLY or produced empty
payloads, because the keepalive loop was never stopped.

Adds markDispatchIdle() alongside markRunComplete() in the followup
runner's finally block, and four test cases covering NO_REPLY, empty
payloads, agent errors, and successful delivery.

Complements #26295 which addressed the channel-level callback layer.

Fixes #26595

Co-authored-by: Samantha <samantha@Samanthas-Mac-mini.local>
2026-02-26 00:53:38 +00:00
Peter Steinberger
70e31c6f68 fix(gateway): harden hooks URL parsing (#26864) 2026-02-26 00:47:35 +00:00
Aleksandrs Tihenko
c0026274d9 fix(auth): distinguish revoked API keys from transient auth errors (#25754)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8f9c07a200
Co-authored-by: rrenamed <87486610+rrenamed@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-25 19:47:16 -05:00
Peter Steinberger
f312222159 test: preserve config exports in agent handler mock 2026-02-26 00:42:51 +00:00
Peter Steinberger
aaeed3c4ea test(agents): add missing announce delivery regressions 2026-02-26 00:38:34 +00:00
Peter Steinberger
20c2db2103 refactor(gateway): split browser auth hardening paths 2026-02-26 01:37:00 +01:00
Peter Steinberger
8f8e46d898 refactor: unify reaction ingress policy guards across channels 2026-02-26 01:34:47 +01:00
Peter Steinberger
876018f322 chore(deps): update dependencies and lockfile 2026-02-26 01:31:36 +01:00
Peter Steinberger
4258a3307f refactor(agents): unify subagent announce delivery pipeline
Co-authored-by: Smith Labs <SmithLabsLLC@users.noreply.github.com>
Co-authored-by: Do Cao Hieu <docaohieu2808@users.noreply.github.com>
2026-02-26 00:30:44 +00:00
Peter Steinberger
aedf62ac7e fix: harden discord and slack reaction ingress authorization 2026-02-26 01:26:47 +01:00
Peter Steinberger
c736f11a16 fix(gateway): harden browser websocket auth chain 2026-02-26 01:22:49 +01:00
Peter Steinberger
f41715a18f refactor(browser): split act route modules and dedupe path guards 2026-02-26 01:21:34 +01:00
Peter Steinberger
046feb6b0e refactor: simplify telegram event authorization flow 2026-02-26 01:14:05 +01:00
Peter Steinberger
496a76c03b fix(security): harden browser trace/download temp path handling 2026-02-26 01:04:05 +01:00
Peter Steinberger
e56b0cf1a0 fix: enforce telegram reaction authorization 2026-02-26 01:03:03 +01:00
Peter Steinberger
c6dfa26f03 refactor(signal): unify reaction auth flow and table-drive tests 2026-02-26 01:02:05 +01:00
Shakker
f83719937a Changelog: note Discord embed fallback coverage 2026-02-25 23:58:42 +00:00
Shakker
a0a229a3bb Discord: align embed fallback in thread starter parsing 2026-02-25 23:58:42 +00:00
User
39cc547f74 fix(discord): include embed title in fallback text (#26907) 2026-02-25 23:58:42 +00:00
Peter Steinberger
b090d6019b test(agent-runner): add overflow empty-payload regression coverage (#26905) 2026-02-25 23:57:58 +00:00
Peter Steinberger
42f455739f fix(security): clarify denyCommands exact-match guidance 2026-02-26 00:55:35 +01:00
Peter Steinberger
eb73e87f18 fix(session): prevent silent overflow on parent thread forks (#26912)
Lands #26912 from @markshields-tl with configurable session.parentForkMaxTokens and docs/tests/changelog updates.

Co-authored-by: Mark Shields <239231357+markshields-tl@users.noreply.github.com>
2026-02-25 23:54:02 +00:00
Peter Steinberger
8d1481cb4a fix(gateway): require pairing for unpaired operator device auth 2026-02-26 00:52:50 +01:00
Peter Steinberger
2aa7842ade fix(signal): enforce auth before reaction notification enqueue 2026-02-26 00:44:46 +01:00
Peter Steinberger
ef326f5cd0 fix(browser): revalidate upload paths at use time 2026-02-26 00:40:56 +01:00
Youyou972
15cfba7075 fix: cron model fallback to agent defaults when payload.model fails (#26717)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 06454bd55b
Co-authored-by: Youyou972 <50808411+Youyou972@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-02-25 23:34:31 +00:00
Peter Steinberger
2011edc9e5 fix(gateway): preserve agentId through gateway send path
Landed from #23249 by @Sid-Qin.
Includes extra regression tests for agentId precedence + blank fallback.

Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com>
2026-02-25 23:31:35 +00:00
Peter Steinberger
125f4071bc fix(gateway): block agents.files symlink escapes 2026-02-26 00:31:08 +01:00
Peter Steinberger
45d59971e6 docs(changelog): clarify macOS beta scope for oauth fix 2026-02-26 00:26:54 +01:00
Peter Steinberger
5325ed90b2 refactor(nextcloud-talk): extract webhook pipeline and shared test harness 2026-02-26 00:23:36 +01:00
Peter Steinberger
f60d9591ef docs(changelog): add macOS auth fix note for setup-token path 2026-02-26 00:23:24 +01:00
Peter Steinberger
d512163d68 fix(security): harden nextcloud-talk webhook replay handling 2026-02-26 00:18:38 +01:00
Peter Steinberger
8f3310000a refactor(macos): remove anthropic oauth onboarding flow 2026-02-26 00:17:03 +01:00
Bill Wang
a898acbd55 feature/OPENCLAW_IMAGE 2026-02-25 10:55:17 -08:00
Bill Wang
98292331d5 Update docker-setup.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-25 10:55:17 -08:00
Bill Wang
c7f88e85b7 feature/OPENCLAW_IMAGE 2026-02-25 10:55:17 -08:00
Bill Wang
15240bdbfe feature/OPENCLAW_IMAGE 2026-02-25 10:55:17 -08:00
Shadow
975c9f4b54 Agents: emphasize config.schema usage 2026-02-25 09:45:39 -06:00
Ayaan Zaidi
b12216af93 fix(android): refresh settings permissions on resume 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
2b7db53d06 fix(android): recover stuck voice sends after missing finals 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
285a0f48e5 fix(android): sync mic manager on toggle 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
f729cc7b07 fix(android): stop auto canvas rehydrate on node connect 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
10a1593e0c feat(android): redesign voice mode layout for full-height conversation 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
f9c3fdba45 refactor(android): expose voice conversation state to viewmodel 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
434dc46531 feat(android): stream voice turns from mic manager events 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
73677f2707 refactor(android): remove legacy voice wake controls from settings 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
6798330c24 feat(android): replace voice placeholder with mic transcript UI 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
3d29233bab feat(android): add single-path mic capture runtime manager 2026-02-25 18:23:50 +05:30
Ayaan Zaidi
90ddb3f271 fix(android): stabilize gateway operator reconnect 2026-02-25 18:23:50 +05:30
Nimrod Gutman
3607b733cb fix(changelog): add typing firestart guard note (#26325) (thanks @win4r) 2026-02-25 14:49:21 +02:00
Ubuntu
a182afcf97 style: expand curly braces per oxfmt 2026-02-25 14:49:21 +02:00
Ubuntu
ae658aa84c style: add curly braces to satisfy eslint(curly) 2026-02-25 14:49:21 +02:00
Ubuntu
97eb5542e8 fix(typing): guard fireStart against post-close invocation
The existing `closed` flag in `createTypingCallbacks` guards
`onReplyStart` but not `fireStart` itself. If a keepalive tick is
already in-flight when `fireStop` sets `closed = true` and calls
`keepaliveLoop.stop()`, the running `onTick → fireStart` callback
still completes and sends a stale `sendChatAction('typing')` after
the reply message has been delivered.

On Telegram (which has no cancel-typing API), this causes the typing
indicator to linger ~5 seconds after the bot's message appears.

Add a `closed` early-return in `fireStart` as defense-in-depth so
that even an in-flight tick is suppressed once cleanup has started.
2026-02-25 14:49:21 +02:00
Nimrod Gutman
b3f46f0e28 fix(test): stabilize low-mem parallel runner and cron session mock (#26324)
* fix(test): stabilize low-mem parallel lane and cron session mock

* feat(android): make QR scanning first-class onboarding

* docs(android): update README for native Android workflow

* fix(android): stabilize chat composer ime and tab layout

* fix(android): stabilize chat ime insets and tab bar

* fix(android): remove tab bar gap above system nav

* fix(android): harden scanned setup code parsing

* test(android): cover non-string setupCode QR payload

* fix(test): add changelog note for low-mem test runner (#26324) (thanks @ngutman)

---------

Co-authored-by: Ayaan Zaidi <zaidi@uplause.io>
2026-02-25 12:16:17 +02:00
Ayaan Zaidi
ed34129637 test(android): cover non-string setupCode QR payload 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
036e3e633e fix(android): harden scanned setup code parsing 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
9c1c083d98 fix(android): remove tab bar gap above system nav 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
7725c0b9b3 fix(android): stabilize chat ime insets and tab bar 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
959cbafcdb fix(android): stabilize chat composer ime and tab layout 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
f894c23e64 docs(android): update README for native Android workflow 2026-02-25 14:05:56 +05:30
Ayaan Zaidi
2e3c05d9da feat(android): make QR scanning first-class onboarding 2026-02-25 14:05:56 +05:30
Nimrod Gutman
56b8c69487 docs(changelog): add discord typing fix entry (#26295) (thanks @ngutman) 2026-02-25 10:21:52 +02:00
Nimrod Gutman
a0fa283839 fix(discord): prevent stuck typing indicator 2026-02-25 10:21:52 +02:00
Ayaan Zaidi
fb76e316fb fix(test): use valid brave ui_lang locale 2026-02-25 11:58:52 +05:30
Brian Mendonca
6bc7544a6a fix(telegram): fail closed on empty group allowFrom override 2026-02-25 11:54:27 +05:30
Ayaan Zaidi
81752564e9 refactor(android): return sendNodeEvent status to callers 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
b065265b73 fix(android): gate canvas restore on node connectivity 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
41870fac16 fix(android): preserve scoped canvas URL suffix on TLS rewrite 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
f701224a69 feat(canvas): add narrow-screen A2UI layout overrides 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
35a4641bb6 fix(android): use mobile viewport settings for canvas webview 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
1c0c58e30d feat(android): add screen-tab canvas restore flow 2026-02-25 11:43:35 +05:30
Ayaan Zaidi
e5399835b2 fix(android): normalize canvas host URLs for TLS gateways 2026-02-25 11:43:35 +05:30
Peter Steinberger
b247cd6d65 fix: harden Slack file-only fallback placeholder (#25181) (thanks @justinhuangcode) 2026-02-25 05:36:49 +00:00
justinhuangcode
a6337be3d1 refactor: use MAX_SLACK_MEDIA_FILES constant for file-only fallback
Replace the hardcoded limit of 5 with the existing
MAX_SLACK_MEDIA_FILES constant (8) from media.ts for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 05:36:49 +00:00
justinhuangcode
def28a87b2 fix(slack): deliver file-only messages when all media downloads fail
When a Slack message contains only files/audio (no text) and every file
download fails, `resolveSlackMedia` returns null and `rawBody` becomes
empty, causing `prepareSlackMessage` to silently drop the message.

Build a fallback placeholder from the original file names so the agent
still receives the message, matching the pattern already used in
`resolveSlackThreadHistory` for file-only thread entries.

Closes #25064
2026-02-25 05:36:49 +00:00
byungsker
177386ed73 fix(tui): resolve wrong provider prefix when session has model without modelProvider (#25874)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f0953a7284
Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-25 00:36:27 -05:00
Peter Steinberger
8f5f599a34 docs(security): note narrow filesystem roots for tool access 2026-02-25 05:10:10 +00:00
Peter Steinberger
52d933b3a9 refactor: replace bot.molt identifiers with ai.openclaw 2026-02-25 05:03:24 +00:00
Glucksberg
6e97470515 fix(brave-search): clarify ui_lang and search_lang format requirements (#25130)
* fix(brave-search): swap ui_lang and search_lang formats (#23826)

* fix(web-search): normalize Brave ui_lang/search_lang params

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:59:38 +00:00
Peter Steinberger
b564b72dc9 docs(changelog): add missing security PR entries (#26118 #26116 #26112 #26111 #26095) 2026-02-25 04:59:10 +00:00
bmendonca3
c1964e73a8 fix(discord): gate component command authorization for guild interactions (#26119)
* Discord: gate component command authorization

* test: cover allowlisted guild component authorization path (#26119) (thanks @bmendonca3)

---------

Co-authored-by: Brian Mendonca <brianmendonca@Brians-MacBook-Air.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:57:41 +00:00
David Rudduck
24a60799be fix(hooks): include guildId and channelName in message_received metadata (#26115)
* fix(hooks): include guildId and channelName in message_received metadata

The message_received hook (both plugin and internal) already exposes
sender identity fields (senderId, senderName, senderUsername, senderE164)
but omits the guild/channel context. Plugins that track per-channel
activity receive NULL values for channel identification.

Add guildId (ctx.GroupSpace) and channelName (ctx.GroupChannel) to the
metadata block in both the plugin hook and internal hook dispatch paths.
These properties are already populated by channel providers (e.g. Discord
sets GroupSpace to the guild ID and GroupChannel to #channel-name) and
used elsewhere in the codebase (channels/conversation-label.ts).

* test: cover guild/channel hook metadata propagation (#26115) (thanks @davidrudduck)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:56:19 +00:00
Sid
2e84017f23 fix(markdown): require paired || delimiters for spoiler detection (#26105)
* fix(markdown): require paired || delimiters for spoiler detection

An unpaired || (odd count across all inline tokens) would open a
spoiler that never closes, causing closeRemainingStyles to extend it
to the end of the text. This made all content after an unpaired ||
appear as hidden/spoiler in Telegram.

Pre-count || delimiters across the entire inline token group and skip
spoiler injection entirely when the count is less than 2 or odd. This
prevents single | characters and unpaired || from triggering spoiler
formatting.

Closes #26068

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: preserve valid spoiler pairs with trailing unmatched delimiters (#26105) (thanks @Sid-Qin)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:54:51 +00:00
Sid
156f13aa64 fix(agents): continue fallback loop for unrecognized provider errors (#26106)
* fix(agents): continue fallback loop for unrecognized provider errors

When a provider returns an error that coerceToFailoverError cannot
classify (e.g., custom error messages without standard HTTP status
codes), the fallback loop threw immediately instead of trying the
next candidate. This caused fallback to stop after 2 models even
when 17 were configured.

Only rethrow unrecognized errors when they occur on the last
candidate. For intermediate candidates, record the error as an
attempt and continue to the next model.

Closes #25926

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover unknown-error fallback telemetry and land #26106 (thanks @Sid-Qin)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:53:26 +00:00
Sid
f7de41ca20 fix(followup): fall back to dispatcher when same-channel origin routing fails (#26109)
* fix(followup): fall back to dispatcher when same-channel origin routing fails

When routeReply fails for an originating channel that matches the
session's messageProvider, the onBlockReply callback was created by
that same channel's handler and can safely deliver the reply.
Previously the payload was silently dropped on any routeReply failure,
causing Feishu DM replies to never reach the user.

Cross-channel fallback (origin ≠ provider) still drops the payload to
preserve origin isolation.

Closes #25767

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: allow same-channel followup fallback routing (#26109) (thanks @Sid-Qin)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-25 04:52:08 +00:00
Brian Mendonca
19d2a8998b security(line): cap unsigned webhook body read budget 2026-02-25 04:50:50 +00:00
Brian Mendonca
107bda27c9 security(msteams): isolate group allowlist from pairing-store entries 2026-02-25 04:49:52 +00:00
Brian Mendonca
d1bed505c5 security(irc): isolate group allowlist from DM pairing store 2026-02-25 04:48:43 +00:00
Brian Mendonca
0a58328217 security(nextcloud-talk): isolate group allowlist from pairing-store entries 2026-02-25 04:47:46 +00:00
Brian Mendonca
09200b3c10 security(nextcloud-talk): reject unsigned webhooks before body read 2026-02-25 04:45:59 +00:00
Peter Steinberger
38c4944d76 docs(security): clarify trusted plugin boundary 2026-02-25 04:39:11 +00:00
Peter Steinberger
146c92069b fix: stabilize live docker test handling 2026-02-25 04:35:05 +00:00
Peter Steinberger
9beec48e9c refactor(agents): centralize model fallback resolution 2026-02-25 04:32:31 +00:00
Peter Steinberger
dd6ad0da8c test(exec): stabilize Windows PATH prepend assertion 2026-02-25 04:29:48 +00:00
Shakker
2652bb1d7d Release: sync plugin versions to 2026.2.25 2026-02-25 04:19:59 +00:00
Ayaan Zaidi
d942e5924e docs: add changelog entry for #26079 (thanks @obviyus) 2026-02-25 09:32:07 +05:30
Ayaan Zaidi
1edd9f8bf5 build(android): migrate to AGP 9 new DSL kotlin setup 2026-02-25 09:32:07 +05:30
Ayaan Zaidi
797843c39a build(android): bump stable dependencies 2026-02-25 09:32:07 +05:30
Ayaan Zaidi
ff4dc050cc feat(android): add gfm chat markdown renderer 2026-02-25 09:32:07 +05:30
Ayaan Zaidi
6969027025 fix(android): restore chat text streaming 2026-02-25 09:32:07 +05:30
Peter Steinberger
d2597d5ecf fix(agents): harden model fallback failover paths 2026-02-25 03:46:34 +00:00
Peter Steinberger
480cc4b85c chore: roll to 2026.2.25 unreleased 2026-02-25 03:35:33 +00:00
Peter Steinberger
51d76eb13a build: switch 2026.2.24 appcast enclosure to stable tag 2026-02-25 03:30:56 +00:00
Peter Steinberger
8930dc0a7b build: update 2026.2.24 appcast enclosure to beta tag 2026-02-25 03:01:48 +00:00
Peter Steinberger
74e5cbfc12 build: update appcast for 2026.2.24 beta 2026-02-25 03:00:45 +00:00
Peter Steinberger
069c495df6 docs: clarify pairing commands in faq and troubleshooting 2026-02-25 02:50:17 +00:00
Peter Steinberger
7c59b78aee test: cap docker live model sweeps and harden timeouts 2026-02-25 02:48:34 +00:00
Peter Steinberger
df9a474891 test: stabilize no-output timeout exec test 2026-02-25 02:46:23 +00:00
Peter Steinberger
c2a837565c docs: fix configure section example 2026-02-25 02:44:49 +00:00
Peter Steinberger
bfafec2271 docs: expand doctor and devices CLI references 2026-02-25 02:41:13 +00:00
Peter Steinberger
a12cbf8994 docs: refresh CLI and trusted-proxy docs 2026-02-25 02:40:12 +00:00
Peter Steinberger
2d1e6931a6 docs(changelog): reorder and backfill 2026.2.24 release notes 2026-02-25 02:33:04 +00:00
Peter Steinberger
9f1bda9802 test: fix TS2742 in telegram media test utils 2026-02-25 02:32:35 +00:00
Peter Steinberger
45b5c35b21 test: fix CI failures in heartbeat and typing tests 2026-02-25 02:28:42 +00:00
Peter Steinberger
d42ef2ac62 refactor: consolidate typing lifecycle and queue policy 2026-02-25 02:16:03 +00:00
Peter Steinberger
24d7612ddf refactor(heartbeat): harden dm delivery classification 2026-02-25 02:13:07 +00:00
Peter Steinberger
91ae82ae19 refactor(sandbox): centralize dangerous docker override key handling 2026-02-25 02:12:15 +00:00
Peter Steinberger
885452f5c1 fix: fail-closed shared-session reply routing (#24571) (thanks @brandonwise) 2026-02-25 02:11:34 +00:00
Peter Steinberger
e28803503d fix: add sandbox bind-override regression coverage (#25410) (thanks @skyer-jian) 2026-02-25 02:09:37 +00:00
Peter Steinberger
a805d6b439 fix(heartbeat): block dm targets and internalize blocked prompts 2026-02-25 02:05:45 +00:00
Peter Steinberger
e0201c2774 fix: keep channel typing active during long inference (#25886, thanks @stakeswky)
Co-authored-by: stakeswky <stakeswky@users.noreply.github.com>
2026-02-25 02:03:27 +00:00
Peter Steinberger
dcd90438ec refactor(telegram-tests): split media suites and decouple store mock 2026-02-25 02:01:52 +00:00
Peter Steinberger
c267b5edf6 refactor(sandbox): unify tmp alias checks and dedupe hardlink tests 2026-02-25 02:01:12 +00:00
Peter Steinberger
eb4a93a8db refactor(sandbox): share container-path utils and tighten fs bridge tests 2026-02-25 01:59:53 +00:00
Peter Steinberger
c736778b3f fix: drop active heartbeat followups from queue (#25610, thanks @mcaxtr)
Co-authored-by: Marcus Castro <mcaxtr@gmail.com>
2026-02-25 01:58:51 +00:00
Peter Steinberger
6fa7226a67 fix: add changelog thanks for #25820 (thanks @bmendonca3) 2026-02-25 01:56:44 +00:00
Brian Mendonca
22689b9dc9 fix(sandbox): reject hardlinked tmp media aliases 2026-02-25 01:56:44 +00:00
Peter Steinberger
a01849e163 test(telegram): cover triple-dash inbound media path regression 2026-02-25 01:54:11 +00:00
Peter Steinberger
fa525bf212 fix(shell): prefer PowerShell 7 on Windows with tested fallbacks (#25684) 2026-02-25 01:49:33 +00:00
Peter Steinberger
bf5a96ad63 fix(agents): keep fallback chain reachable on configured fallback models (#25922) 2026-02-25 01:46:20 +00:00
Peter Steinberger
559b5eab71 fix(cli): support --query in memory search command (#25904) 2026-02-25 01:41:56 +00:00
Brandon Wise
389ccda0f6 fix: remove unused DeliverableMessageChannel import 2026-02-25 01:41:31 +00:00
Brandon Wise
f35c902bd6 style: fix oxfmt formatting in targets.test.ts 2026-02-25 01:41:31 +00:00
Brandon Wise
455fbc6b6d fix(security): prevent cross-channel reply routing in shared sessions 2026-02-25 01:41:31 +00:00
Peter Steinberger
c7ae4ed04d fix: harden sandbox fs dash-path regression coverage (#25891) (thanks @albertlieyingadrian) 2026-02-25 01:40:30 +00:00
Albert Lie
5e3502df5f fix(sandbox): prevent shell option interpretation for paths with leading hyphens
Paths starting with "-" (like those containing "---" pattern) can be
interpreted as shell options by the sh shell. This fix adds a helper
function that prepends "./" to paths starting with "-" to prevent
this interpretation.

This fixes the issue where sandbox filesystem operations fail with
"Syntax error: ; unexpected" when file paths contain the "---" pattern
used in auto-generated inbound media filenames like:
file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.ogg

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-25 01:40:30 +00:00
Peter Steinberger
b35d00aaf8 fix: sanitize Gemini 3.1 Google reasoning payloads 2026-02-25 01:40:14 +00:00
Peter Steinberger
039713c3e7 fix: suppress reasoning payload leakage in whatsapp replies 2026-02-25 01:36:37 +00:00
Peter Steinberger
a177b10b79 test(windows): normalize risky-path assertions 2026-02-25 01:28:47 +00:00
Peter Steinberger
e2362d352d fix(heartbeat): default target none and internalize relay prompts 2026-02-25 01:28:47 +00:00
Peter Steinberger
4d89548e59 fix(ui): inherit default model fallbacks in agents overview (#25729)
Land PR #25729 from @Suko.
Use shared fallback-resolution helper and add regression coverage for default, override, and explicit-empty cases.

Co-authored-by: suko <miha.sukic@gmail.com>
2026-02-25 01:28:17 +00:00
Peter Steinberger
43f318cd9a fix(agents): reduce billing false positives on long text (#25680)
Land PR #25680 from @lairtonlelis.
Retain explicit status/code/http 402 detection for oversized structured payloads.

Co-authored-by: Ailton <lairton@telnyx.com>
2026-02-25 01:22:17 +00:00
Peter Steinberger
7dfac70185 fix(synology-chat): land @bmendonca3 fail-closed allowlist follow-up (#25827)
Carry fail-closed empty-allowlist guard clarity and changelog attribution for PR #25827.

Co-authored-by: Brian Mendonca <brianmendonca@Brians-MacBook-Air.local>
2026-02-25 01:19:43 +00:00
Peter Steinberger
0078070680 fix(telegram): refresh global undici dispatcher for autoSelectFamily (#25682)
Land PR #25682 from @lairtonlelis after maintainer rework:
track dispatcher updates when network decision changes to avoid stale global fetch behavior.

Co-authored-by: Ailton <lairton@telnyx.com>
2026-02-25 01:16:03 +00:00
Peter Steinberger
bd213cf2ad fix(agents): normalize SiliconFlow Pro thinking=off payload (#25435)
Land PR #25435 from @Zjianru.
Changelog: add 2026.2.24 fix entry with contributor credit.

Co-authored-by: codez <codezhujr@gmail.com>
2026-02-25 01:11:34 +00:00
Peter Steinberger
8470dff619 chore(deps): update dependencies except carbon 2026-02-25 01:10:36 +00:00
Peter Steinberger
2157c490af test: normalize tmp media path assertion for windows 2026-02-25 00:58:17 +00:00
Peter Steinberger
5c6b2cbc8e refactor: extract iMessage echo cache and unify suppression guards 2026-02-25 00:53:39 +00:00
Peter Steinberger
196a7dbd24 test(media): add win32 dev=0 local media regression 2026-02-25 00:47:02 +00:00
Peter Steinberger
2a11c09a8d fix: harden iMessage echo dedupe and reasoning suppression (#25897) 2026-02-25 00:46:56 +00:00
Peter Steinberger
a9ce6bd79b refactor: dedupe exec wrapper denial plan and test setup 2026-02-25 00:43:29 +00:00
Peter Steinberger
943b8f171a fix: align windows safe-open file identity checks 2026-02-25 00:42:04 +00:00
shenghui kevin
7455ceecf8 fix(windows): skip unreliable dev comparison in fs-safe openVerifiedLocalFile
On Windows, device IDs (dev) returned by handle.stat() and fs.lstat()
may differ even for the same file, causing false-positive 'path-mismatch'
errors when reading local media files.

This fix introduces a statsMatch() helper that:
- Always compares inode (ino) values
- Skips device ID (dev) comparison on Windows where it's unreliable
- Maintains full comparison on Unix platforms

Fixes #25699
2026-02-25 00:42:04 +00:00
Peter Steinberger
3c95f89662 refactor(exec): split system.run phases and align ts/swift validator contracts 2026-02-25 00:35:06 +00:00
Peter Steinberger
b0f392580b docs(changelog): remove next-release shipping sentence 2026-02-25 00:35:06 +00:00
Peter Steinberger
a1a6235c66 test: bridge discord voice private casts via unknown 2026-02-25 00:31:17 +00:00
Peter Steinberger
ee6fec36eb docs(discord): document DAVE defaults and decrypt recovery 2026-02-25 00:28:06 +00:00
Peter Steinberger
ce1dbeb986 fix(macos): clean warnings and harden gateway/talk config parsing 2026-02-25 00:27:36 +00:00
Peter Steinberger
9cd50c51b0 fix(discord): harden voice DAVE receive reliability (#25861)
Reimplements and consolidates related work:
- #24339 stale disconnect/destroyed session guards
- #25312 voice listener cleanup on stop
- #23036 restore @snazzah/davey runtime dependency

Adds Discord voice DAVE config passthrough, repeated decrypt failure
rejoin recovery, regression tests, docs, and changelog updates.

Co-authored-by: Frank Yang <frank.ekn@gmail.com>
Co-authored-by: Do Cao Hieu <admin@docaohieu.com>
2026-02-25 00:19:50 +00:00
Vincent Koc
1839ba8ccb Changelog: note allowlist stale-catalog model selection fix 2026-02-24 19:16:02 -05:00
Vincent Koc
5509bf2c75 Gateway tests: include synthetic allowlist models in models.list 2026-02-24 19:16:02 -05:00
Vincent Koc
f7cf3d0dad Gateway tests: accept allowlisted refs absent from catalog 2026-02-24 19:16:02 -05:00
Vincent Koc
f34325ec01 Tests: cover allowlist refs missing from catalog 2026-02-24 19:16:02 -05:00
Vincent Koc
e9068e2571 Agents: trust explicit allowlist refs beyond catalog 2026-02-24 19:16:02 -05:00
Peter Steinberger
16b228e4a6 fix(macos): resolve webchat panel corner clipping (#22458)
Co-authored-by: apethree <3081182+apethree@users.noreply.github.com>
Co-authored-by: agisilaos <3073709+agisilaos@users.noreply.github.com>
2026-02-25 00:14:56 +00:00
Peter Steinberger
57c9a18180 fix(security): block env depth-overflow approval bypass 2026-02-25 00:14:13 +00:00
Peter Steinberger
1970a1e9e5 fix(macos): keep Return for IME marked text commit (#25178)
Co-authored-by: jft0m <9837901+bottotl@users.noreply.github.com>
2026-02-25 00:14:00 +00:00
Peter Steinberger
11a0495d5f fix(macos): default voice wake forwarding to webchat (#25440)
Co-authored-by: Peter Machona <7957943+chilu18@users.noreply.github.com>
2026-02-25 00:12:44 +00:00
Vincent Koc
30082c9af1 Update CHANGELOG.md 2026-02-24 19:12:08 -05:00
Vincent Koc
99dd3448e8 Changelog: remove unrelated session entries from PR 2026-02-24 19:12:08 -05:00
Vincent Koc
1cb14fcf1c Changelog: note OpenRouter cooldown bypass 2026-02-24 19:12:08 -05:00
Vincent Koc
aee38c42d3 Tests: preserve OpenRouter explicit auth order under cooldown fields 2026-02-24 19:12:08 -05:00
Vincent Koc
06f0b4a193 Tests: keep OpenRouter runnable with legacy cooldown markers 2026-02-24 19:12:08 -05:00
Vincent Koc
ebc8c4b609 Tests: skip OpenRouter failure cooldown persistence 2026-02-24 19:12:08 -05:00
Vincent Koc
5de04960a0 Tests: cover OpenRouter cooldown display bypass 2026-02-24 19:12:08 -05:00
Vincent Koc
f1d5c1a31f Auth: use cooldown helper in explicit profile order 2026-02-24 19:12:08 -05:00
Vincent Koc
daa4f34ce8 Auth: bypass cooldown tracking for OpenRouter 2026-02-24 19:12:08 -05:00
Peter Steinberger
31e6d18538 fix(macos): prefer openclaw binary while keeping pnpm fallback (#25512)
Co-authored-by: Peter Machona <7957943+chilu18@users.noreply.github.com>
2026-02-25 00:11:53 +00:00
Peter Steinberger
236b22b6a2 fix(macos): guard voice audio paths with no input device (#25817)
Co-authored-by: Stefan Förster <103369858+sfo2001@users.noreply.github.com>
2026-02-25 00:10:14 +00:00
Peter Steinberger
e11e510f5b docs(changelog): add reporter credit for exec companion hardening 2026-02-25 00:06:14 +00:00
Peter Steinberger
97e56cb73c fix(discord): land proxy/media/reaction/model-picker regressions
Reimplements core Discord fixes from #25277 #25523 #25575 #25588 #25731 with expanded tests.

- thread proxy-aware fetch into inbound attachment/sticker downloads
- fetch /gateway/bot via proxy dispatcher before ws connect
- wire statusReactions emojis/timing overrides into controller
- compact model-picker custom_id keys with backward-compatible parsing

Co-authored-by: openperf <openperf@users.noreply.github.com>
Co-authored-by: chilu18 <chilu18@users.noreply.github.com>
Co-authored-by: Yipsh <Yipsh@users.noreply.github.com>
Co-authored-by: lbo728 <lbo728@users.noreply.github.com>
Co-authored-by: s1korrrr <s1korrrr@users.noreply.github.com>
2026-02-25 00:03:30 +00:00
Peter Steinberger
55cf92578d fix(security): harden system.run companion command binding 2026-02-25 00:02:03 +00:00
Peter Steinberger
8680240f7e docs(changelog): backfill landed fix PR entries 2026-02-24 23:59:04 +00:00
Fred White
b7deb062ea fix: normalize "bedrock" provider ID to "amazon-bedrock"
Add "bedrock" and "aws-bedrock" as aliases for the canonical
"amazon-bedrock" provider ID in normalizeProviderId().

Without this mapping, configuring a model as "bedrock/..." causes
the auth resolution fallback to miss the Bedrock-specific AWS SDK
path, since the fallback check requires normalized === "amazon-bedrock".
This primarily affects the main agent when the explicit auth override
is not preserved through config merging.

Fixes #15716
2026-02-24 23:57:11 +00:00
suko
b3e6653503 fix(onboard): avoid false 'telegram plugin not available' block 2026-02-24 23:55:27 +00:00
Peter Steinberger
b0bb3cca8a test(types): fix ts narrowing regressions in followup and matrix queue tests 2026-02-24 23:54:51 +00:00
Mark Musson
e22a2d77ba fix(whatsapp): stop retry loop on non-retryable 440 close 2026-02-24 23:52:49 +00:00
Peter Steinberger
def993dbd8 refactor(tmp): harden temp boundary guardrails 2026-02-24 23:51:10 +00:00
Vincent Koc
de586373e0 Changelog: note exact do not do that stop trigger 2026-02-24 18:50:53 -05:00
Vincent Koc
cc386f4962 Telegram tests: route exact do not do that to control lane 2026-02-24 18:50:53 -05:00
Vincent Koc
83f586b93b Gateway tests: cover exact do not do that stop matching 2026-02-24 18:50:53 -05:00
Vincent Koc
91391bbe01 Auto-reply tests: assert exact do not do that behavior 2026-02-24 18:50:53 -05:00
Vincent Koc
7bb08ba945 Auto-reply: add exact stop trigger for do not do that 2026-02-24 18:50:53 -05:00
Brian Mendonca
a3c4f56b0b security(voice-call): detect Telnyx webhook replay 2026-02-24 23:50:30 +00:00
Peter Steinberger
53f9b7d4e7 fix(automation): harden announce delivery + cron coding profile (#25813 #25821 #25822)
Co-authored-by: Shawn <shenghuikevin@shenghuideMac-mini.local>
Co-authored-by: 不做了睡大觉 <user@example.com>
Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
2026-02-24 23:49:34 +00:00
Peter Steinberger
36d1e1dcff refactor(telegram): simplify DM media auth precheck flow 2026-02-24 23:49:10 +00:00
Peter Steinberger
316fad13aa refactor(outbound): unify attachment hydration flow 2026-02-24 23:48:43 +00:00
Brian Mendonca
9924f7c84e fix(security): classify hook sessions case-insensitively 2026-02-24 23:48:09 +00:00
Brian Mendonca
43a3ff3beb Changelog: add entry for exec env sanitization 2026-02-24 23:46:39 +00:00
Brian Mendonca
48b052322b Security: sanitize inherited host exec env 2026-02-24 23:46:39 +00:00
Peter Steinberger
9514201fb9 fix(telegram): block unauthorized DM media downloads 2026-02-24 23:44:50 +00:00
Brian Mendonca
5a64f6d766 Gateway/Security: protect /api/channels plugin root 2026-02-24 23:44:32 +00:00
Peter Steinberger
453664f09d refactor(zalo): split monitor access and webhook logic 2026-02-24 23:40:51 +00:00
Peter Steinberger
58309fd8d9 refactor(matrix,tests): extract helpers and inject send-queue timing 2026-02-24 23:37:50 +00:00
Peter Steinberger
a2529c25ff test(matrix,discord,sandbox): expand breakage regression coverage 2026-02-24 23:37:50 +00:00
Peter Steinberger
13a1c46396 fix(web-search): reduce provider auto-detect log noise 2026-02-24 23:32:29 +00:00
Peter Steinberger
79a7b3d22e test(line): align tmp-root expectation after sandbox hardening 2026-02-24 23:31:54 +00:00
Peter Steinberger
79e2328935 docs: update changelog for safe-bin hardening 2026-02-24 23:30:55 +00:00
Peter Steinberger
b4010a0b62 fix(zalo): enforce group sender policy in groups 2026-02-24 23:30:43 +00:00
Peter Steinberger
4355e08262 refactor: harden safe-bin trusted dir diagnostics 2026-02-24 23:29:44 +00:00
Peter Steinberger
5c2a483375 refactor(outbound): centralize attachment media policy 2026-02-24 23:29:05 +00:00
Peter Steinberger
54648a9cf1 refactor: centralize followup origin routing helpers 2026-02-24 23:28:58 +00:00
Peter Steinberger
9b53102100 test: add routing/session isolation edge-case regressions 2026-02-24 23:28:58 +00:00
Peter Steinberger
9fccf60733 refactor(synology-chat): centralize DM auth and fail fast startup 2026-02-24 23:28:40 +00:00
Peter Steinberger
e7a5f9f4d8 fix(channels,sandbox): land hard breakage cluster from reviewed PR bases
Lands reviewed fixes based on #25839 (@pewallin), #25841 (@joshjhall), and #25737/@25713 (@DennisGoldfinger/@peteragility), with additional hardening + regression tests for queue cleanup and shell script safety.

Fixes #25836
Fixes #25840
Fixes #25824
Fixes #25868

Co-authored-by: Peter Wallin <pwallin@gmail.com>
Co-authored-by: Joshua Hall <josh@yaplabs.com>
Co-authored-by: Dennis Goldfinger <dennisgoldfinger@gmail.com>
Co-authored-by: peteragility <peteragility@users.noreply.github.com>
2026-02-24 23:27:56 +00:00
Peter Steinberger
5552f9073f refactor(sandbox): centralize network mode policy helpers 2026-02-24 23:26:46 +00:00
Peter Steinberger
14b6eea6e3 feat(sandbox): block container namespace joins by default 2026-02-24 23:20:34 +00:00
Peter Steinberger
ccbeb332e0 fix: harden routing/session isolation for followups and heartbeat 2026-02-24 23:20:27 +00:00
Peter Steinberger
7655c0cb3a docs(changelog): add synology-chat allowlist fail-closed note 2026-02-24 23:18:18 +00:00
Peter Steinberger
0ee30361b8 fix(synology-chat): fail closed empty allowlist 2026-02-24 23:18:17 +00:00
Peter Steinberger
270ab03e37 fix: enforce local media root checks for attachment hydration 2026-02-24 23:17:48 +00:00
Peter Steinberger
b67e600bff fix(security): restrict default safe-bin trusted dirs 2026-02-24 23:13:37 +00:00
Peter Steinberger
2d159e5e87 docs(security): document openclaw temp-folder boundary 2026-02-24 23:11:19 +00:00
Peter Steinberger
d3da67c7a9 fix(security): lock sandbox tmp media paths to openclaw roots 2026-02-24 23:10:19 +00:00
Peter Steinberger
bf8ca07deb fix(config): soften antigravity removal fallout (#25538)
Land #25538 by @chilu18 to keep legacy google-antigravity-auth config entries non-fatal after removal (see #25862).

Co-authored-by: chilu18 <chilu.machona@icloud.com>
2026-02-24 23:02:45 +00:00
Shakker
039ae0b77c chore: refresh lockfile after plugin devDependency cleanup 2026-02-24 22:50:47 +00:00
Shakker
955cc9029f chore: sync plugin versions to 2026.2.24 2026-02-24 22:45:46 +00:00
Peter Steinberger
f4e6f87303 refactor(ios): drop legacy talk payload and keychain fallbacks 2026-02-24 22:39:37 +00:00
Shakker
853f75592f changelog: include #25847 in chat image safety entry (#25847) (thanks @shakkernerd) 2026-02-24 22:28:58 +00:00
Shakker
30cb849b10 test(ui): reject base64 SVG data URLs 2026-02-24 22:28:58 +00:00
Shakker
e7298b844f changelog: credit both chat-image fix contributors 2026-02-24 22:28:58 +00:00
Shakker
e9750104b2 ui: block svg data image opens and harden tests 2026-02-24 22:28:58 +00:00
Peter Steinberger
9ef0fc2ff8 fix(sandbox): block @-prefixed workspace path bypass 2026-02-24 17:23:14 +00:00
Ayaan Zaidi
f154926cc0 fix: land telegram empty-html fallback hardening (#25096) (thanks @Glucksberg) 2026-02-24 22:34:21 +05:30
Ayaan Zaidi
6e31bca198 fix(telegram): fail loud on empty text fallback 2026-02-24 22:34:21 +05:30
Glucksberg
566a8e7137 chore(telegram): suppress handled empty-text retry logs 2026-02-24 22:34:21 +05:30
Glucksberg
51b3e23680 fix(telegram): fallback to plain text when threaded markdown renders empty
Minimal fix path for Telegram empty-text failures in threaded replies.

- fallback to plain text when formatted htmlText is empty
- retry plain text on parse/empty-text API errors
- add focused regression test for threaded mode case

Related: #25091
Supersedes alternative fix path in #17629 if maintainers prefer minimal scope.
2026-02-24 22:34:21 +05:30
Ayaan Zaidi
00de3ca833 fix: widen external link rel token set type 2026-02-24 22:15:42 +05:30
Ayaan Zaidi
8892a1cd45 refactor(android-ui): unify gateway config resolution paths 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
7a74cf34ba fix(android-security): remove token-derived logging from prefs 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
8b24830e07 fix(android-gateway): avoid token clear on transient connect failure 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
e11e329238 refactor(android-chat): move thread selector above composer 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
75f145ebcc docs(android): document alpha rebuild status and feature checklist 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
baf98a87f6 refactor(android-settings): remove gateway controls duplicated in connect 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
bb27884474 feat(android-tabs): add coming-soon voice and screen tabs 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
94f426b29e fix(android-nav): hide tab bar while keyboard is open 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
577c554150 style(android-chat): redesign composer controls and actions 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
81ff074a51 style(android-chat): align bubbles and markdown with RN 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
b658000bf7 style(android-chat): refine thread shell and empty states 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
02e3fbef77 style(android): align settings screen with RN visual system 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
cf031d6ad4 chore(android): remove unused legacy ui components 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
439d8e609e fix(android): use native client id for operator session 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
14f5217e22 fix(android): retry with shared token after device-token failure 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
4b188dcf97 fix(android): persist gateway auth state across onboarding 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
f853622eca feat(android): switch post-onboarding app to five-tab shell 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
c015382a77 feat(android): add connect tab screen with setup and manual modes 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
757a4dc9fa docs(android): generalize style guide from onboarding baseline 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
5d88e77420 docs(android): add native UI style guide 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
d6bbe93d4c feat(android): add settings action to rerun onboarding 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
b9cc2599f1 feat(android): add native four-step onboarding flow 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
3e2e010952 feat(android): add onboarding and gateway auth state plumbing 2026-02-24 22:13:49 +05:30
Ayaan Zaidi
36c352453f build(android): bump AGP and update gradle defaults 2026-02-24 22:13:49 +05:30
Peter Steinberger
e806b34779 chore: remove changelog add helper script 2026-02-24 15:33:09 +00:00
Peter Steinberger
0f0a680d3d fix(exec): block shell-wrapper positional argv approval smuggling 2026-02-24 15:17:03 +00:00
Peter Steinberger
80daaeba38 fix(ios): split watch notify normalization helpers
Co-authored-by: Mariano Belinky <mbelinky@gmail.com>
2026-02-24 15:16:11 +00:00
Mariano Belinky
4ec0af00fe Agents: fix embedded auth-profile failure helper typing 2026-02-24 15:16:11 +00:00
Mariano Belinky
0121fa6f1a Changelog: add PR 23636 iOS/watch notes 2026-02-24 15:16:11 +00:00
Mariano Belinky
d06d8701fd iOS: normalize watch quick actions and fix test signing 2026-02-24 15:16:11 +00:00
Peter Steinberger
d18ae2256f refactor: unify channel plugin resolution, family ordering, and changelog entry tooling 2026-02-24 15:15:22 +00:00
Peter Steinberger
878b4e0ed7 refactor: unify tools.fs workspaceOnly resolution 2026-02-24 15:14:05 +00:00
Peter Steinberger
6c5ab543c0 refactor: tighten external-link policy and window.open guard 2026-02-24 15:05:31 +00:00
Peter Steinberger
13bfe7faa6 refactor(sandbox): share bind parsing and host-path policy checks 2026-02-24 15:04:47 +00:00
Peter Steinberger
0e155690be fix(config): add operational guidance to legacy talk help
Co-authored-by: Nimrod Gutman <nimrod.g@singular.net>
2026-02-24 15:02:52 +00:00
Peter Steinberger
44162055a8 fix(config): dedupe talk schema help keys 2026-02-24 15:02:52 +00:00
Nimrod Gutman
d58f71571a feat(talk): add provider-agnostic config with legacy compatibility 2026-02-24 15:02:52 +00:00
Nimrod Gutman
d1f28c954e feat(gateway): surface talk elevenlabs config metadata 2026-02-24 15:02:52 +00:00
Peter Steinberger
069c56cd75 fix(ios): normalize team IDs before preferred match
Co-authored-by: Brian Leach <bleach@gmail.com>
2026-02-24 15:02:27 +00:00
Peter Steinberger
1ae8c0a589 fix(ios): make team-id python lookup cross-platform
Co-authored-by: Brian Leach <bleach@gmail.com>
2026-02-24 15:02:27 +00:00
Peter Steinberger
fd07861bc3 fix(ios): harden team-id profile fallback and tests 2026-02-24 15:02:27 +00:00
Brian Leach
73f526f025 fix(ios): support Xcode 16+ team detection and fix ntohl build error
Xcode 16+/26 no longer writes IDEProvisioningTeams to the preferences
plist, breaking ios-team-id.sh for newly signed-in accounts. Add
provisioning profile fallback and actionable error when an account
exists but no team ID can be resolved. Also replace ntohl() with
UInt32(bigEndian:) for Swift 6 compatibility and gitignore Xcode
build output directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:02:27 +00:00
Peter Steinberger
9ccc15f3a6 docs: update changelog note for native image workspace fix 2026-02-24 14:55:42 +00:00
Peter Steinberger
3b4dac764b fix: doctor plugin-id mapping for channel auto-enable (#25275) (thanks @zerone0x) 2026-02-24 14:55:23 +00:00
zerone0x
203de14211 fix(doctor): use plugin manifest id for third-party channel auto-enable
When a third-party channel plugin declares a channel ID that differs from
its plugin ID (e.g. plugin id="apn-channel", channels=["apn"]), the
doctor plugin auto-enable logic was using the channel ID ("apn") as the
key for plugins.entries, producing an entry that fails config validation:
  Error: plugins.entries.apn: plugin not found: apn

Root cause: resolveConfiguredPlugins iterated over cfg.channels keys and
used each key directly as both the channel ID (for isChannelConfigured)
and the plugin ID (for plugins.entries). For built-in channels these are
always the same, but for third-party plugins they can differ.

Fix: load the installed plugin manifest registry and build a reverse map
from channel ID to plugin ID. When a cfg.channels key does not resolve to
a built-in channel, look up the declaring plugin's manifest ID and use
that as the pluginId in the PluginEnableChange, so registerPluginEntry
writes the correct plugins.entries["apn-channel"] key.

The applyPluginAutoEnable function now accepts an optional manifestRegistry
parameter for testing, avoiding filesystem access in unit tests.

Fixes #25261

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-24 14:55:23 +00:00
Peter Steinberger
3f07d725b1 fix: changelog credit for Telegram IPv4 fallback fix (#24295) (thanks @Glucksberg)
Co-Authored-By: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
2026-02-24 14:53:01 +00:00
Glucksberg
dd9ba974d0 fix: sort IPv4 addresses before IPv6 in SSRF pinned DNS to fix Telegram media fetch on IPv6-broken hosts
On hosts where IPv6 is configured but not routed (common on cloud VMs),
Telegram media downloads fail because the pinned DNS lookup may return
IPv6 addresses first. Even though autoSelectFamily (Happy Eyeballs) is
enabled, the round-robin pinned lookup serves individual IPv6 addresses
that fail before IPv4 is attempted.

Sort resolved addresses so IPv4 comes first, ensuring both Happy Eyeballs
and single-address round-robin try the working address family first.

Fixes #23975

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:53:01 +00:00
Peter Steinberger
fb8edebc32 fix(ui): stabilize chat-image open browser test and changelog 2026-02-24 14:48:10 +00:00
Shakker
94942df8c7 build: scope window.open guard to ui checks 2026-02-24 14:48:10 +00:00
Shakker
e5836283ab ui: centralize safe external URL opening 2026-02-24 14:48:10 +00:00
Shakker
ebb5680893 ui(chat): allowlist image open URLs 2026-02-24 14:48:10 +00:00
Peter Steinberger
370d115549 fix: enforce workspaceOnly for native prompt image autoload 2026-02-24 14:47:59 +00:00
Peter Steinberger
c3680c2277 docs(changelog): credit reporter for sandbox bind-path fix 2026-02-24 14:47:56 +00:00
Peter Steinberger
9168f2147f test: add case-insensitive stop abort assertions 2026-02-24 14:47:48 +00:00
Peter Steinberger
6da03eabe2 fix: add changelog and clean regression comment for tool-result guard (#25429) (thanks @mikaeldiakhate-cell) 2026-02-24 14:42:09 +00:00
Leakim
8db7ca8c02 fix: prevent synthetic toolResult for aborted/errored assistant messages
When an assistant message with toolCalls has stopReason 'aborted' or 'error',
the guard should not add those tool call IDs to the pending map. Creating
synthetic tool results for incomplete/aborted tool calls causes API 400 errors:
'unexpected tool_use_id found in tool_result blocks'

This aligns the WRITE path (session-tool-result-guard.ts) with the READ path
(session-transcript-repair.ts) which already skips aborted messages.

Fixes: orphaned tool_result causing session corruption

Tests added:
- does NOT create synthetic toolResult for aborted assistant messages
- does NOT create synthetic toolResult for errored assistant messages
2026-02-24 14:42:09 +00:00
zzzz
31b1b20b3c docs: add WeChat community plugin listing
Add @icesword760/openclaw-wechat to the community plugins page.
This plugin connects OpenClaw to WeChat personal accounts via
WeChatPadPro (iPad protocol) with support for text, image, and
file exchange.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 08:41:28 -06:00
Peter Steinberger
760671e31c fix: add changelog for kimi cache usage parsing (#25436) (thanks @Elarwei001) 2026-02-24 14:40:52 +00:00
Elarwei
aa2826b5b1 fix(usage): parse Kimi K2 cached_tokens from prompt_tokens_details
Kimi K2 models use automatic prefix caching and return cache stats in
a nested field: usage.prompt_tokens_details.cached_tokens

This fixes issue #7073 where cacheRead was showing 0 for K2.5 users.

Also adds cached_tokens (top-level) for moonshot-v1 explicit caching API.

Closes #7073
2026-02-24 14:40:52 +00:00
Peter Steinberger
b511a38fc8 fix: add changelog for doctor sandbox docker warning (#25438) (thanks @mcaxtr) 2026-02-24 14:40:06 +00:00
Marcus Castro
23b9daee6f fix(doctor): improve sandbox warning when Docker unavailable 2026-02-24 14:40:06 +00:00
Peter Steinberger
d2c031de84 fix: add changelog for meta timestamp coercion (#25491) (thanks @mcaxtr) 2026-02-24 14:39:12 +00:00
Marcus Castro
2c4ebf77f3 fix(config): coerce numeric meta.lastTouchedAt to ISO string 2026-02-24 14:39:12 +00:00
Peter Steinberger
b5787e4abb fix(sandbox): harden bind validation for symlink missing-leaf paths 2026-02-24 14:37:35 +00:00
Peter Steinberger
0365125c21 fix: add changelog for reset hook fallback coverage (#25459) (thanks @chilu18) 2026-02-24 14:27:48 +00:00
chilu18
aec41a588b fix(hooks): backfill reset command hooks for native /new path 2026-02-24 14:27:48 +00:00
Peter Steinberger
bbdf895d42 fix: add changelog for slug generator model resolution (#25485) (thanks @SudeepMalipeddi) 2026-02-24 14:27:01 +00:00
SudeepMalipeddi
d32298cbd8 fix: slug-generator uses effective model instead of agent-primary
resolveAgentModelPrimary() only checks the agent-level model config and
does not fall back to the system-wide default. When users configure a
non-Anthropic provider (e.g. Gemini, Minimax) as their global default
without setting it at the agent level, the slug-generator falls through
to DEFAULT_PROVIDER (anthropic) and fails with a missing API key error.

Switch to resolveAgentEffectiveModelPrimary() which correctly respects
the full model resolution chain including global defaults.

Fixes #25365
2026-02-24 14:27:01 +00:00
Peter Steinberger
5e6fe9c160 fix: add changelog for slack dm channel-type guard (#25479) (thanks @mcaxtr) 2026-02-24 14:26:01 +00:00
Marcus Castro
3ff6e078ec test(slack): add missing allowNameMatching field to DM classification tests 2026-02-24 14:26:01 +00:00
Marcus Castro
f33d0a884e fix(slack): override wrong channel_type for D-prefix DM channels 2026-02-24 14:26:01 +00:00
Peter Steinberger
8cc841766c docs(security): enumerate dangerous config parameters 2026-02-24 14:25:43 +00:00
Peter Steinberger
39631639b7 fix: add changelog + typed omission test note (#25314) (thanks @lbo728) 2026-02-24 14:22:02 +00:00
lbo728
b863316e7b fix(models): preserve user reasoning override when merging with built-in catalog
When a built-in provider model has reasoning:true (e.g. MiniMax-M2.5) and
the user explicitly sets reasoning:false in their config, mergeProviderModels
unconditionally overwrote the user's value with the built-in catalog value.

The merge code refreshes capability metadata (input, contextWindow, maxTokens,
reasoning) from the implicit catalog. This is correct for fields like
contextWindow and maxTokens — the catalog has authoritative values that
shouldn't be stale. But reasoning is a user preference, not just a
capability descriptor: users may need to disable it to avoid 'Message
ordering conflict' errors with certain models or backends.

Fix: check whether 'reasoning' is present in the explicit (user-supplied)
model entry. If the user has set it (even to false), honour that value.
If the user hasn't set it, fall back to the built-in catalog default.

This allows users to configure tools.models.providers.minimax.models with
reasoning:false for MiniMax-M2.5 without being silently overridden.

Fixes #25244
2026-02-24 14:22:02 +00:00
Peter Steinberger
07f653ffc8 fix: polish bare wildcard allowlist handling (#25250) (thanks @widingmarcus-cyber) 2026-02-24 14:20:11 +00:00
Marcus Widing
0f0b2c0255 fix(exec): match bare * wildcard in allowlist entries (#25082)
The matchAllowlist() function skipped patterns without path separators
(/, \, ~), causing a bare "*" wildcard entry to never reach the glob
matcher. Since glob's single * maps to [^/]*, it would also fail against
absolute paths. Handle bare "*" as a special case that matches any
resolved executable path.

Closes #25082
2026-02-24 14:20:11 +00:00
Peter Steinberger
e9216cb7dc fix: add changelog for trusted-proxy pairing bypass (#25428) (thanks @SidQin-cyber) 2026-02-24 14:17:54 +00:00
SidQin-cyber
20523b918a fix(gateway): allow trusted-proxy control-ui auth to skip device pairing
Control UI connections authenticated via gateway.auth.mode=trusted-proxy were
still forced through device pairing because pairing bypass only considered
shared token/password auth (sharedAuthOk). In trusted-proxy deployments,
this produced persistent "pairing required" failures despite valid trusted
proxy headers.

Treat authenticated trusted-proxy control-ui connections as pairing-bypass
eligible and allow missing device identity in that mode.

Fixes #25293

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 14:17:54 +00:00
Peter Steinberger
d84659f22f fix: add changelog for block-reply flush await (#25427) (thanks @SidQin-cyber) 2026-02-24 14:11:40 +00:00
SidQin-cyber
99d854db82 fix(agents): await block-reply flush before tool execution starts
handleToolExecutionStart() flushed pending block replies and then called
onBlockReplyFlush() as fire-and-forget (`void`). This created a race where
fast tool results (especially media on Telegram) could be delivered before
the text block that preceded the tool call.

Await onBlockReplyFlush() so the block pipeline finishes before tool
execution continues, preserving delivery order.

Fixes #25267

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 14:11:40 +00:00
Peter Steinberger
4d124e4a9b feat(security): warn on likely multi-user trust-model mismatch 2026-02-24 14:03:19 +00:00
DoncicX
32d7756d8c iOS: extract device/platform info into DeviceInfoHelper, keep Settings platform string as iOS X.Y.Z 2026-02-24 13:56:43 +00:00
Peter Steinberger
e3ac491da3 docs(changelog): trim 2026.2.24 unreleased entries 2026-02-24 13:51:45 +00:00
Peter Steinberger
7c99a733a9 fix: harden macOS usage cost submenu recursion guard (#25341) (thanks @yingchunbai) 2026-02-24 13:48:59 +00:00
yingchunbai
96b21f4823 fix(macos): remove self-delegate on cost usage submenu to prevent recursive dropdown
The cost usage submenu set `menu.delegate = self` (the MenuSessionsInjector),
which caused `menuWillOpen(_:)` to call `inject(into:)` on the submenu when
it opened. This re-inserted the "Usage cost (30 days)" item into the submenu,
creating an infinite recursive dropdown.

Fix: remove the delegate assignment from the submenu — it does not need
the injector's delegate behavior since it only contains a static chart view.

Closes #25167

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:48:59 +00:00
Peter Steinberger
2bad30b4d3 chore(release): bump version to 2026.2.24 2026-02-24 13:42:43 +00:00
Shakker
aceb17a30e changelog: add entry for PR 18685 fix 2026-02-24 13:04:10 +00:00
Mariana Sinisterra
649d141527 fix(ui): prevent tabnabbing in chat images (#18685)
* UI: prevent tabnabbing in chat images

* ui: remove comment from image open helper

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-02-24 12:56:08 +00:00
LawrenceLuo
66e61ca6ce docs: fix broken links in README (#25368)
- /start/faq → /help/faq
- /concepts/groups → /channels/groups
- /concepts/group-messages → /channels/group-messages
- /concepts/channel-routing → /channels/channel-routing

Co-authored-by: LawrenceLuo <5390633+PinoHouse@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-24 06:27:23 -06:00
Peter Machona
097a6a83a0 fix(cli): replace stale doctor/restart command hints (#24485)
* fix(cli): replace stale doctor and restart hints

* fix: add changelog for CLI hint updates (#24485) (thanks @chilu18)

---------

Co-authored-by: Muhammed Mukhthar CM <mukhtharcm@gmail.com>
2026-02-24 14:49:59 +05:30
Val Alexander
1c228dc249 docs: add Val Alexander to maintainers list (#25197)
* docs: add Val Alexander to maintainers list

- Focus: UI/UX, Docs, and Agent DevX
- GitHub: @BunsDev
- X/Twitter: @BunsDev

* Update CONTRIBUTING.md

* fix: format
2026-02-24 01:50:30 -06:00
Vincent Koc
4b316c33db Auto-reply: normalize stop matching and add multilingual triggers (#25103)
* Auto-reply tests: cover multilingual abort triggers

* Auto-reply: normalize multilingual abort triggers

* Gateway: route chat stop matching through abort parser

* Gateway tests: cover chat stop parsing variants

* Auto-reply tests: cover Russian and German stop words

* Auto-reply: add Russian and German abort triggers

* Gateway tests: include Russian and German stop forms

* Telegram tests: route Russian and German stop forms to control lane

* Changelog: note multilingual abort stop coverage

* Changelog: add shared credit for abort shortcut update
2026-02-24 01:07:25 -05:00
Peter Steinberger
b817600533 chore(release): cut 2026.2.23 2026-02-24 05:39:22 +00:00
Peter Steinberger
8ea936cdda docs: clarify prompt caching intro 2026-02-24 05:22:00 +00:00
Peter Steinberger
cafa8226d7 docs(changelog): move stop-signal expansion to changes 2026-02-24 05:14:02 +00:00
Peter Steinberger
936f2449bd chore(release): prep 2026.2.23-beta.1 changelog 2026-02-24 05:02:40 +00:00
Peter Steinberger
fd10286819 docs(changelog): mark allowFrom id-only default as breaking 2026-02-24 04:47:36 +00:00
Peter Steinberger
91ea6ad8ec docs(changelog): reorder unreleased fixes by user impact 2026-02-24 04:46:19 +00:00
Arturo
10cd4b5e68 chore: credit PR #24705 contributor attribution
Attribution-only commit for the bot-authored upstream patch landed from #24705.
2026-02-24 04:44:11 +00:00
Peter Steinberger
ee42381951 chore: add mailmap mappings for cherry-picked contributors 2026-02-24 04:43:28 +00:00
Peter Steinberger
31f2bf9519 test: fix gate regressions 2026-02-24 04:39:53 +00:00
Peter Steinberger
2d6d6797d8 test: fix post-merge config and tui command-handler tests 2026-02-24 04:38:21 +00:00
justinhuangcode
6ea1607f1c test(discord): add regression tests for reasoning tag stripping in stream
Verify that partial stream updates containing <thinking> tags are stripped
before reaching the draft preview, and that pure "Reasoning:\n" partials
are suppressed entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:37:30 +00:00
justinhuangcode
e8a4d5d9bd fix(discord): strip reasoning tags from partial stream preview
When streamMode is "partial", reasoning/thinking block content can leak
into the Discord draft preview because the partial text is forwarded to
the draft stream without filtering.  Apply `stripReasoningTagsFromText`
before updating the draft and skip pure-reasoning messages (those
starting with "Reasoning:\n") so internal thinking traces never reach
the user-visible preview.

Fixes #24532

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:37:30 +00:00
justinhuangcode
0ded77ca7d test(matrix): add regression tests for reasoning-only reply filtering
Verify that deliverMatrixReplies skips replies whose text starts with
"Reasoning:\n" or opens with <thinking>/<think>/<antthinking> tags, while
still delivering all normal replies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:36:27 +00:00
justinhuangcode
1298bd4e1b fix(matrix): skip reasoning-only messages in reply delivery
When `includeReasoning` is active (or `reasoningLevel` falls back to the
model default), the agent emits reasoning blocks as separate reply
payloads prefixed with "Reasoning:\n".  Matrix has no dedicated reasoning
lane, so these internal thinking traces leak into the chat as regular
user-visible messages.

Filter out pure-reasoning payloads (those starting with "Reasoning:\n" or
a `<thinking>` tag) before delivery so internal reasoning never reaches
the Matrix room.

Fixes #24411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:36:27 +00:00
Peter Steinberger
5ac70b36a4 test: make shell-env trust-path test platform-safe (#24991) (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
Peter Steinberger
d3ecc234da test: align flaky CI expectations after main changes (#24991) (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
Peter Steinberger
cb450fd31f fix: align lockfile with diagnostics-otel proto deps (#24991) (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
Peter Steinberger
2880fb3cb8 fix: sync lockfile for diagnostics-otel deps (#24991) (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
Peter Steinberger
19d0ddc679 fix: regenerate protocol swift models for nodeId (#24991) (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
Peter Steinberger
d427d09b5e fix: align reasoning payload typing for #24991 (thanks @stakeswky) 2026-02-24 04:34:49 +00:00
User
7d76c241f8 fix: suppress reasoning payloads from generic channel dispatch path
When reasoningLevel is 'on', reasoning content was being sent as a
visible message to WhatsApp and other non-Telegram channels via two
paths:
1. Block reply: emitted via onBlockReply in handleMessageEnd
2. Final payloads: added to replyItems in buildEmbeddedRunPayloads

Telegram has its own dispatch path (bot-message-dispatch.ts) that
splits reasoning into a dedicated lane and handles suppression.
The generic dispatch-from-config.ts path used by WhatsApp, web, etc.
had no such filtering.

Fix:
- Add isReasoning?: boolean flag to ReplyPayload
- Tag reasoning payloads at both emission points
- Filter isReasoning payloads in dispatch-from-config.ts for both
  block reply and final reply paths

Telegram is unaffected: it uses its own deliver callback that detects
reasoning via the 'Reasoning:\n' prefix and routes to a separate lane.

Fixes #24954
2026-02-24 04:34:49 +00:00
Workweaver Ralph
b9e587fb63 fix(tui): guard sendMessage when disconnected; reset readyPromise on close
(cherry picked from commit df827c3eef)
2026-02-24 04:33:51 +00:00
Shennan
a7518b7589 fix(feishu): pass parentPeer for topic session binding inheritance
(cherry picked from commit bddeb1fd95)
2026-02-24 04:33:51 +00:00
Marco Di Dionisio
83689fc838 fix: include trusted-proxy in sharedAuthOk check
In trusted-proxy mode, sharedAuthResult is null because hasSharedAuth
only triggers for token/password in connectParams.auth. But the primary
auth (authResult) already validated the trusted-proxy — the connection
came from a CIDR in trustedProxies with a valid userHeader. This IS
shared auth semantically (the proxy vouches for identity), so operator
connections should be able to skip device identity.

Without this fix, trusted-proxy operator connections are rejected with
"device identity required" because roleCanSkipDeviceIdentity() sees
sharedAuthOk=false.

(cherry picked from commit e87048a6a6)
2026-02-24 04:33:51 +00:00
zerone0x
bc52d4a459 fix(openrouter): skip reasoning effort injection for 'auto' routing model
The 'auto' model on OpenRouter dynamically routes to any underlying model
OpenRouter selects, including reasoning-required endpoints. Previously,
OpenClaw would unconditionally inject `reasoning.effort: "none"` into
every request when the thinking level was "off", which causes a 400 error
on models where reasoning is mandatory and cannot be disabled.

Root cause:
- openrouter/auto has reasoning: false in the built-in catalog
- With thinking level "off", createOpenRouterWrapper injects
  `reasoning: { effort: "none" }` via mapThinkingLevelToOpenRouterReasoningEffort
- For any OpenRouter-routed model that requires reasoning this results in:
  "400 Reasoning is mandatory for this endpoint and cannot be disabled"
- The reasoning: false is then persisted back to models.json on every
  ensureOpenClawModelsJson call, so manually removing it has no lasting effect

Fix:
- In applyExtraParamsToAgent, when provider is "openrouter" and the model
  id is "auto", pass undefined as thinkingLevel to createOpenRouterWrapper
  so no reasoning.effort is injected at all, letting OpenRouter's upstream
  model handle it natively
- Add an explanatory comment in buildOpenrouterProvider clarifying that the
  reasoning: false catalog value does NOT cause effort injection for "auto"

Users who need explicit reasoning control should target a specific model
id (e.g. openrouter/deepseek/deepseek-r1) rather than the auto router.

Fixes #24851

(cherry picked from commit aa55439798)
2026-02-24 04:33:51 +00:00
Ben Marvell
eae13d9367 test(agents): update test to match universal tool-result repair for OpenAI
The previous test asserted that OpenAI-responses sessions would NOT get
synthetic tool results for orphaned tool calls. With repairToolUseResultPairing
now running universally, the correct behavior is that orphaned tool calls
get a synthetic tool_result — matching what OpenAI actually requires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 2edb0ffe0b)
2026-02-24 04:33:51 +00:00
Ben Marvell
252079f001 fix(agents): repair orphaned tool results for OpenAI after history truncation
repairToolUseResultPairing was gated behind !isOpenAi, skipping orphaned
tool_result cleanup for OpenAI providers. When limitHistoryTurns truncated
conversation history, tool_result messages whose matching tool_call was
before the truncation point survived and were sent as function_call_output
items with stale call_id references. OpenAI rejects these with:
"No tool call found for function call output with call_id ..."

Enable the repair universally — all providers need it after truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 97b065aa6e)
2026-02-24 04:33:50 +00:00
chilu18
424ba72cad fix(config): add actionable guidance for dmPolicy open allowFrom mismatch
(cherry picked from commit d3bfbdec5d)
2026-02-24 04:33:50 +00:00
chilu18
8c8374defa fix(cron): treat embedded error payloads as run failures
(cherry picked from commit 50fd31c070)
2026-02-24 04:33:50 +00:00
Marc Gratch
75969ed5c4 fix(plugins): pass session context to before_compaction hook in subscribe handler
The handleAutoCompactionStart handler was calling runBeforeCompaction with
only messageCount and an empty hook context. Plugins receiving this hook
could not identify the session or snapshot the transcript during
auto-compaction.

The other call site in compact.ts already passes the full payload
(messages, sessionFile, sessionKey). This aligns the subscribe handler
to do the same using ctx.params.session and ctx.params.sessionKey.

(cherry picked from commit 318a19d1a1)
2026-02-24 04:33:50 +00:00
Marcus Castro
58ce0a89ec fix(cli): load plugin registry for configure and onboard commands (#17266)
(cherry picked from commit 644badd40d)
2026-02-24 04:33:50 +00:00
JackyWay
792bd6195c fix: recognize Bedrock as Anthropic-compatible in transcript policy
(cherry picked from commit 3b5154081c)
2026-02-24 04:33:50 +00:00
github-actions[bot]
3823587ada fix(agents): allow empty edit replacement text
(cherry picked from commit 3c21fc30d3)
2026-02-24 04:33:50 +00:00
Glucksberg
fd7ca4c394 fix: normalize input peer.kind in resolveAgentRoute (#22730)
The input peer.kind from channel plugins was used as-is without
normalization via normalizeChatType(), while the binding side correctly
normalized. This caused "dm" !== "direct" mismatches in
matchesBindingScope, making plugins that use "dm" as peerKind fail to
match bindings configured with "direct".

Normalize both peer.kind and parentPeer.kind through normalizeChatType()
so that "dm" and "direct" are treated equivalently on both sides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit b0c96702f5)
2026-02-24 04:33:50 +00:00
HCL
24e52f53e4 fix(cli): resolve --url option collision in browser cookies set
When addGatewayClientOptions registers --url on the parent browser
command, Commander.js captures it before the cookies set subcommand
can receive it. Switch from requiredOption to option and resolve
via inheritOptionFromParent, matching the existing pattern used
for --target-id.

Fixes #24811

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 96fcb963ec)
2026-02-24 04:33:50 +00:00
Brian Mendonca
d51a4695f0 Deny cron tool on /tools/invoke by default
(cherry picked from commit 816a6b3a4d)
2026-02-24 04:33:50 +00:00
Peter Steinberger
f9de17106a refactor(browser): share relay token + options validation tests 2026-02-24 04:23:22 +00:00
Peter Steinberger
8c5cf2d5b2 docs(subagents): document default runTimeoutSeconds config (#24594) (thanks @mitchmcalister) 2026-02-24 04:22:43 +00:00
Mitch McAlister
8bcd405b1c fix: add .int() to runTimeoutSeconds zod schema for consistency
Matches convention used by all other *Seconds/*Ms timeout fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:22:43 +00:00
Mitch McAlister
5710d72527 feat(agents): configurable default runTimeoutSeconds for subagent spawns
When sessions_spawn is called without runTimeoutSeconds, subagents
previously defaulted to 0 (no timeout). This adds a config key at
agents.defaults.subagents.runTimeoutSeconds so operators can set a
global default timeout for all subagent runs.

The agent-provided value still takes precedence when explicitly passed.
When neither the agent nor the config specifies a timeout, behavior is
unchanged (0 = no timeout), preserving backwards compatibility.

Updated for the subagent-spawn.ts refactor (logic moved from
sessions-spawn-tool.ts to spawnSubagentDirect).

Closes #19288

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:22:43 +00:00
Peter Steinberger
803e02d8df fix: adapt landed fixups to current type and approval constraints 2026-02-24 04:20:30 +00:00
Peter Steinberger
dd14daab15 fix(telegram): allowlist api.telegram.org in media SSRF policy 2026-02-24 04:20:30 +00:00
zerone0x
ac6cec7677 fix(providers): strip trailing /v1 from Anthropic baseUrl to prevent double-path
The pi-ai Anthropic provider constructs the full API endpoint as
`${baseUrl}/v1/messages`. If a user configures
`models.providers.anthropic.baseUrl` with a trailing `/v1`
(e.g. "https://api.anthropic.com/v1"), the resolved URL becomes
"https://api.anthropic.com/v1/v1/messages" which the Anthropic API
rejects with a 404 / connection failure.

This regression appeared in v2026.2.22 when @mariozechner/pi-ai bumped
from 0.54.0 to 0.54.1, which started appending the /v1 segment where
the previous version did not.

Fix: in normalizeModelCompat(), detect anthropic-messages models and
strip a single trailing /v1 (with optional trailing slash) from the
configured baseUrl before it is handed to pi-ai. Models with baseUrls
that do not end in /v1 are unaffected. Non-anthropic-messages models
are not touched.

Adds 6 unit tests covering the normalisation scenarios.

Fixes #24709

(cherry picked from commit 4c4857fdcb)
2026-02-24 04:20:30 +00:00
Marcus Castro
01c1f68ab3 fix(hooks): decouple message:sent internal hook from mirror param
(cherry picked from commit 1afd7030f8)
2026-02-24 04:20:30 +00:00
User
c7bf0dacb8 chore: remove unused isMinimal param from buildSkillsSection
Address review feedback: isMinimal is no longer referenced after the
early-return guard was removed in the parent commit.

(cherry picked from commit 2efe04d301)
2026-02-24 04:20:30 +00:00
User
2398b51378 fix: include available_skills in isolated cron agentTurn sessions (closes #24888)
buildSkillsSection() had an early-return guard on isMinimal that silently
dropped the entire <available_skills> block for any session using
promptMode="minimal" — which includes all isolated cron agentTurn sessions
(isCronSessionKey → promptMode="minimal" in attempt.ts:497-500).

Fix: remove the isMinimal guard from buildSkillsSection so that skills are
emitted whenever a non-empty skillsPrompt is provided, regardless of mode.
Memory, docs, reply-tags, and other verbose sections remain gated on isMinimal.

Tests added:
- "includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)"
- "omits skills in minimal prompt mode when skillsPrompt is absent"
- Updated existing minimal-mode test expectation to match corrected behaviour.

(cherry picked from commit 66af86e7ee)
2026-02-24 04:20:30 +00:00
zerone0x
c69fc383b9 fix(config): surface helpful chown hint on EACCES when reading config
When the gateway is deployed in a Docker/container environment using a
1-click hosting template, the openclaw.json config file can end up owned
by root (mode 600) while the gateway process runs as the non-root 'node'
user. This causes a silent EACCES failure: the gateway starts with an
empty config and Telegram/Discord bots stop responding.

Before this fix the error was logged as a generic 'read failed: ...'
message with no indication of how to recover.

After this fix:
- EACCES errors log a clear, actionable error to stderr (visible in
  docker logs) with the exact chown command to run
- The config snapshot issue message also includes the chown hint so
  'openclaw gateway status' / Control UI surface the fix path
- process.getuid() is used to include the current UID in the hint;
  falls back to '1001' on platforms where it is unavailable

Fixes #24853

(cherry picked from commit 0a3c572c41)
2026-02-24 04:20:30 +00:00
SidQin-cyber
f3459d71e8 fix(exec): treat shell exit codes 126/127 as failures instead of completed
When a command exits with code 127 (command not found) or 126 (not
executable), the exec tool previously returned status "completed" with
the error buried in the output text. This caused cron jobs to report
status "ok" and never increment consecutiveErrors, silently swallowing
failures like `python: command not found` across multiple daily cycles.

Now these shell-reserved exit codes are classified as "failed", which
propagates through the cron pipeline to properly increment
consecutiveErrors and surface the issue for operator attention.

Fixes #24587

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 2b1d1985ef)
2026-02-24 04:20:30 +00:00
damaozi
c6bb7b0c04 fix(whatsapp): groupAllowFrom sender filter bypassed when groupPolicy is allowlist (#24670)
(cherry picked from commit af06ebd9a6)
2026-02-24 04:20:30 +00:00
Brian Mendonca
3f5e7f8156 fix(gateway): consume allow-once approvals to prevent replay
(cherry picked from commit 6adacd447c)
2026-02-24 04:20:30 +00:00
Peter Steinberger
ffc22778f3 fix(subagents): prune orphaned restored runs + status wording (#24244) (thanks @HeMuling) 2026-02-24 04:17:56 +00:00
HeMuling
3c13f4c2b4 test(subagents): mock sessions store in steer-restart coverage 2026-02-24 04:17:56 +00:00
HeMuling
d0e008d460 chore(status): clarify bootstrap file semantics 2026-02-24 04:17:56 +00:00
HeMuling
c3b3065cc9 fix(subagents): reconcile orphaned restored runs 2026-02-24 04:17:56 +00:00
Peter Steinberger
cd3927ad67 fix(sessions): preserve allow-any subagent model overrides (#21088) (thanks @Slats24) 2026-02-24 04:16:32 +00:00
Slats
87dd896963 fix: sessions_sspawn model override ignored for sub-agents
Fix bug where sessions_spawn model parameter was ignored, causing sub-agents
   to always use the parent's default model.

   The allowAny flag from buildAllowedModelSet() was not being captured or used.

   🤖 AI-assisted (Claude) - fully tested locally

   Fixes #17479, #6295, #10963
2026-02-24 04:16:32 +00:00
Peter Steinberger
f6b4baa776 test(telegram): align stop-phrase sequential key expectation (#25034) 2026-02-24 04:16:17 +00:00
Peter Steinberger
1237516ae8 fix(chrome-extension): finalize relay endpoint validation flow (#22252) (thanks @krizpoon) 2026-02-24 04:16:08 +00:00
Kriz Poon
b7949d317f Chrome extension: simplify validation logic
Use OR operator to require both Browser and Protocol-Version fields. Simplified catch block to generic error message since specific wrong-port cases are already handled by the validation blocks above.
2026-02-24 04:16:08 +00:00
Kriz Poon
0a53a77dd6 Chrome extension: validate relay endpoint response format
Options page now validates that /json/version returns valid CDP JSON (with Browser/Protocol-Version fields) rather than accepting any HTTP 200 response. This prevents false success when users mistakenly configure the gateway port instead of the relay port (gateway + 3).

Helpful error messages now guide users to use "gateway port + 3" when they configure the wrong port.
2026-02-24 04:16:08 +00:00
Kriz Poon
1fdaaaedd3 Docs: clarify Chrome extension relay port derivation (gateway + 3) 2026-02-24 04:16:08 +00:00
Keith
b2719d00ff fix(subagents): restore isInternalMessageChannel guard in resolveAnnounceOrigin
Restores the narrower internal-channel guard from PR #22223 (fe57bea08) that was
inadvertently reverted by f555835b0.

The original !isDeliverableMessageChannel() check strips the requester's channel
whenever it is not in the registered deliverable set. This causes delivery
failures for plugin channels whose adapter ID differs from their plugin ID (e.g.
"gmail" vs "openclaw-gmail"): the requester origin is discarded and the announce
falls back to stale session routes — typically WhatsApp — resulting in a timeout
followed by an E.164 format error.

Replacing with isInternalMessageChannel() limits stripping to explicitly internal
channels (webchat), preserving the requester origin for all external channels
regardless of whether they are currently in the deliverable list.

Fixes: #22223 regression introduced in f555835b0

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-02-24 04:13:40 +00:00
Sahil Satralkar
420d8c663c Tests/Typing: stabilize subagent completion routing changes 2026-02-24 04:12:25 +00:00
Sahil Satralkar
8796c78b3d Gateway: propagate message target and thread headers into tools invoke context 2026-02-24 04:12:25 +00:00
Sahil Satralkar
f9ffd41cfa Subagents: fallback completion announce to internal session when outbound route is incomplete 2026-02-24 04:12:25 +00:00
Sahil Satralkar
28d658e178 Tests: verify tools invoke propagates route headers for subagent spawn context 2026-02-24 04:12:25 +00:00
Sahil Satralkar
3eabd53898 Tests: add regressions for subagent completion fallback and explicit direct route 2026-02-24 04:12:25 +00:00
Peter Steinberger
004a61056c docs(changelog): note relay nav auto-reattach fix (#19766) (thanks @nishantkabra77) 2026-02-24 04:11:13 +00:00
NK
7c028e8c09 fix: respect canceled_by_user and replaced_with_devtools detach reasons
Skip re-attach when user explicitly dismisses debugger bar or opens
DevTools. Prevents frustrating re-attach loop that fights user intent.

Addresses review feedback from greptile-apps.
2026-02-24 04:11:13 +00:00
NK
67bac62c2c fix: Chrome relay extension auto-reattach after SPA navigation
When Chrome's debugger detaches during page navigation (common in SPAs
like Gmail, Google Calendar), the extension now automatically re-attaches
instead of permanently losing the connection.

Changes:
- onDebuggerDetach: detect navigation vs tab close, attempt re-attach
  with 3 retries and exponential backoff (300ms, 700ms, 1500ms)
- Add reattachPending guard to prevent concurrent re-attach races
- connectOrToggleForActiveTab: handle pending re-attach state
- onRelayClosed: clear reattachPending on relay disconnect
- Add chrome.tabs.onRemoved listener for proper cleanup

Fixes #19744
2026-02-24 04:11:13 +00:00
Peter Steinberger
721d8b2278 test(discord): stabilize parent-info + doctor migration assertions (#25028) 2026-02-24 04:10:52 +00:00
Marcus Castro
dd41a78458 fix(bluebubbles): pass SSRF policy for localhost attachment downloads (#24457)
(cherry picked from commit aff64567c7)
2026-02-24 04:06:57 +00:00
Peter Steinberger
113545f005 docs(changelog): note browser control startup import fix (#23974) (thanks @ieaves) 2026-02-24 04:06:03 +00:00
Ian Eaves
3129d1c489 fix(gateway): start browser HTTP control server module 2026-02-24 04:06:03 +00:00
root
8d2035633b fix(agents): include SOUL.md, IDENTITY.md, USER.md in subagent/cron bootstrap allowlist
Subagent and isolated cron sessions only loaded AGENTS.md and TOOLS.md,
causing subagents to lose their role personality, identity, and user
preferences. Expand MINIMAL_BOOTSTRAP_ALLOWLIST to include the three
missing identity files.

Closes #24852

(cherry picked from commit c33377150e)
2026-02-24 04:04:35 +00:00
SidQin-cyber
9d3bd50990 fix(otel): use protobuf OTLP exporters instead of JSON/HTTP
The diagnostics-otel extension validates that protocol is "http/protobuf"
but was importing JSON-based `-http` exporters. This caused silent failures
with backends like VictoriaMetrics that only accept protobuf-encoded OTLP.

Switch all three exporter imports (metrics, traces, logs) from
`@opentelemetry/exporter-*-otlp-http` to `@opentelemetry/exporter-*-otlp-proto`.

Fixes #24942

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit f5c0bf0497)
2026-02-24 04:04:35 +00:00
Peter Steinberger
aea28e26fb fix(auto-reply): expand standalone stop phrases 2026-02-24 04:02:43 +00:00
Peter Steinberger
588a188d6f fix: replace stale plugin webhook routes on re-registration 2026-02-24 04:01:41 +00:00
Peter Steinberger
d76742ff88 fix: normalize manifest plugin ids during install 2026-02-24 03:56:34 +00:00
Peter Steinberger
a388fbb6c3 fix: harden custom-provider verification probes (#24743) (thanks @Glucksberg) 2026-02-24 03:56:30 +00:00
Peter Steinberger
ebde897bb8 fix: add dmScope route guard regression tests (#24949) (thanks @kevinWangSheng) 2026-02-24 03:55:29 +00:00
shenghui kevin
57783680ad fix(whatsapp): guard updateLastRoute when dmScope isolates DM sessions
When session.dmScope is set to 'per-channel-peer', WhatsApp DMs correctly
resolve isolated session keys, but updateLastRouteInBackground unconditionally
wrote lastTo to the main session key. This caused reply routing corruption
and privacy violations.

Only update main session's lastRoute when the DM session actually IS
the main session (sessionKey === mainSessionKey).

Fixes #24912
2026-02-24 03:55:29 +00:00
shenghui kevin
6f44d92d76 docs: update PR_STATUS.md - all 11 PRs CI passed 2026-02-24 03:55:29 +00:00
Peter Steinberger
de0e01259a fix: expand openrouter thinking-off regression coverage (#24863) (thanks @DevSecTim) 2026-02-24 03:54:29 +00:00
Tim Jones
b96d32c1c2 chore: fix oxfmt formatting in extraparams test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 03:54:29 +00:00
Tim Jones
3e974dc93f fix: don't inject reasoning: { effort: "none" } for OpenRouter when thinking is off
"off" is a truthy string, so the existing guard `if (thinkingLevel && ...)`
was always entering the injection block and sending `reasoning: { effort: "none" }`
to every OpenRouter request — even when thinking wasn't enabled. Models that
require reasoning (e.g. deepseek/deepseek-r1) reject this with:
  400 Reasoning is mandatory for this endpoint and cannot be disabled.

Fix: skip the reasoning injection entirely when thinkingLevel is "off".
The reasoning_effort flat-field cleanup still runs. Omitting the reasoning
field lets each model use its own default behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 03:54:29 +00:00
Peter Steinberger
69a541c3f0 fix: sanitize pairing recovery requestId hints (#24771) (thanks @markmusson) 2026-02-24 03:53:45 +00:00
Mark Musson
b902d5ade0 fix(status): show pairing approval recovery hints 2026-02-24 03:53:45 +00:00
Peter Steinberger
6c1ed9493c fix: harden queue retry debounce and add regression tests 2026-02-24 03:52:49 +00:00
Peter Steinberger
a216f2dabe fix: extend discord thread parent fallback coverage (#24897) (thanks @z-x-yang) 2026-02-24 03:52:43 +00:00
Zongxin Yang
d883ecade6 fix(discord): fallback thread parent lookup when parentId missing 2026-02-24 03:52:43 +00:00
Peter Steinberger
fd24b35449 fix: cover startup locale hydration path (#24795) (thanks @chilu18) 2026-02-24 03:51:58 +00:00
chilu18
053b0df7d4 fix(ui): load saved locale on startup 2026-02-24 03:51:58 +00:00
Peter Steinberger
7a42558a3e fix: harden legacy plugin schema compatibility tests (#24933) (thanks @pandego) 2026-02-24 03:50:53 +00:00
pandego
9f4764cd41 fix(plugins): guard legacy zod schemas without toJSONSchema 2026-02-24 03:50:53 +00:00
Peter Steinberger
dd145f1346 fix: suppress sessions_send warning leakage coverage (#24740) (thanks @Glucksberg) 2026-02-24 03:49:52 +00:00
Glucksberg
947883d2e0 fix: suppress sessions_send error warnings from leaking to chat (#23989)
sessions_send timeout/error results were being surfaced as raw warning
messages in Telegram chats because the tool is classified as mutating,
which forces error warnings to always be shown. However, sessions_send
failures are transient inter-session communication issues where the
message may still have been delivered, so they should not leak to users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 03:49:52 +00:00
Peter Steinberger
9cc7450edf docs(changelog): add missing unreleased fixes and reorder 2026-02-24 03:48:49 +00:00
Glucksberg
1565d7e7b3 fix: increase verification max_tokens to 1024 for Poe API compatibility
Poe API's Extended Thinking models (e.g. claude-sonnet-4.6) require
budget_tokens >= 1024. The previous values (5 for OpenAI, 16 for
Anthropic) caused HTTP 400 errors during provider verification.

Fixes #23433

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 03:47:49 +00:00
Peter Steinberger
b5881d9ef4 fix: avoid WhatsApp silent turns with final-only delivery (#24962) (thanks @SidQin-cyber) 2026-02-24 03:47:20 +00:00
SidQin-cyber
3d22af692c fix(whatsapp): suppress reasoning/thinking content from WhatsApp delivery
The deliver callback in process-message.ts was forwarding all payload
kinds (tool, block, final) to WhatsApp. Block payloads contain the
model's reasoning/thinking content, which should only be visible in
the internal web UI. This caused chain-of-thought to leak to end users
as separate WhatsApp messages.

Add an early return for non-final payloads so only the actual response
is delivered to the WhatsApp channel, matching how Telegram already
filters by info.kind === "final".

Fixes #24954
Fixes #24605

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:47:20 +00:00
Peter Steinberger
3a653082d8 fix(config): align whatsapp enabled schema with auto-enable 2026-02-24 03:39:41 +00:00
Coy Geek
aef45b2abb fix(logging): redact phone numbers and message content from WhatsApp logs
Apply redactIdentifier() (SHA-256 hashing) to all recipient JIDs and
phone numbers logged by sendMessageWhatsApp, sendReactionWhatsApp,
sendPollWhatsApp, and runWebHeartbeatOnce. Remove poll question text
and message preview content from log entries, replacing with character
counts where useful for debugging.

The existing redactIdentifier() utility in src/logging/redact-identifier.ts
was already implemented but not wired into any WhatsApp logging path.
This commit connects it to all affected call sites while leaving
functional parameters (actual send calls, event emitters) untouched.

Closes #24957
2026-02-24 03:36:29 +00:00
Peter Steinberger
0bdcca2f35 test(whatsapp): add log redaction coverage 2026-02-24 03:34:31 +00:00
Sid
d95ee859f8 fix(cron): use full prompt mode for isolated cron sessions to include skills (#24944)
Isolated cron sessions (agentTurn) were grouped with subagent sessions
under the "minimal" prompt mode, which causes buildSkillsSection to
return an empty array. This meant <available_skills> was never included
in the system prompt for isolated cron runs.

Subagent sessions legitimately need minimal prompts (reduced context),
but isolated cron sessions are full agent turns that should have access
to all configured skills, matching the behavior of normal chat sessions
and non-isolated cron runs.

Remove isCronSessionKey from the minimal prompt condition so only
subagent sessions use "minimal" mode.

Fixes openclaw#24888

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:33:54 +00:00
zerone0x
bf91b347c1 fix(plugins): use manifest id as config entry key instead of npm package name (#24796)
* fix(plugins): use manifest id as config key instead of npm package name

Plugin manifests (openclaw.plugin.json) define a canonical 'id' field that
is used as the authoritative plugin identifier by the manifest registry.
However, the install command was deriving the config entry key from the npm
package name (e.g. 'cognee-openclaw') rather than the manifest id (e.g.
'memory-cognee'), causing a latent mismatch.

On the next gateway reload the plugin could not be found under the config key
derived from the npm package name, causing 'plugin not found' errors and
potentially shutting the gateway down.

Fix: after extracting the package directory, read openclaw.plugin.json and
prefer its 'id' field over the npm package name when registering the config
entry. Falls back to the npm-derived id if the manifest file is absent or
has no valid id. A diagnostic info message is emitted when the two values
differ so the mismatch is visible in the install log.

The update path (src/plugins/update.ts) already correctly reads the manifest
id and is unaffected.

Fixes #24429

* fix: format plugin install manifest-id path (#24796)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-24 03:33:51 +00:00
Sid
f5cab29ec7 fix(synology-chat): deregister stale webhook route before re-registering on restart (#24971)
When the Synology Chat plugin restarts (auto-restart or health monitor),
startAccount is called again without calling the previous stop(). The
HTTP route is still registered, so registerPluginHttpRoute returns a
no-op unregister function and logs "already registered". This triggers
another restart, creating an infinite loop.

Store the unregister function at module level keyed by account+path.
Before registering, check for and call any stale unregister from the
previous start cycle, ensuring a clean slate for route registration.

Fixes #24894

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:33:47 +00:00
Peter Machona
9ced64054f fix(auth): classify missing OAuth scopes as auth failures (#24761) 2026-02-24 03:33:44 +00:00
Sid
38da3f40cb fix(discord): suppress reasoning/thinking block payloads from delivery (#24969)
Block payloads (info.kind === "block") contain reasoning/thinking content
that should only be visible in the internal web UI. When streamMode is
"partial", these blocks were being delivered to Discord as visible
messages, leaking chain-of-thought to end users.

Add an early return for block payloads in the deliver callback,
consistent with the WhatsApp fix and Telegram's existing behavior.

Fixes #24532

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:33:40 +00:00
Sid
c1fe688d40 fix(gateway): safely extract text from content arrays in prompt builder (#24946)
* fix(gateway): safely extract text from message content arrays in prompt builder

When HistoryEntry.body is a content array (e.g. [{type:"text",
text:"hello"}]) rather than a plain string, template literal
interpolation produces "[object Object]" instead of the actual message
text. This affects users whose session messages were stored with array
content format.

Add a safeBody helper that detects non-string body values and uses
extractTextFromChatContent to extract the text, preventing the
[object Object] serialization in both the current-message return path
and the history formatting path.

Fixes openclaw#24688

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: format gateway agent prompt helper (#24946)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-24 03:33:37 +00:00
banna-commits
e3da57d956 fix: add exponential backoff to announce queue drain on failure (#24783)
When the gateway rejects connections (e.g. scope-upgrade 'pairing required'),
the announce queue drain loop would retry every ~1s indefinitely because
the only delay was the fixed debounceMs (default 1000ms).

This adds a consecutiveFailures counter with exponential backoff:
2s, 4s, 8s, 16s, 32s, 60s (capped). The counter resets on successful drain.

The backoff is applied by shifting lastEnqueuedAt forward so that
waitForQueueDebounce naturally delays the next attempt.

Fixes #24777

Co-authored-by: Knut <knut@Knut-sin-Mac-mini.local>
2026-02-24 03:33:34 +00:00
青雲
52ac7634db fix: persist reasoningLevel 'off' instead of deleting it (#24406) (#24559)
When a user runs /reasoning off, the session patch handler deleted
the reasoningLevel field from the session entry. This caused
get-reply-directives to treat reasoning as 'not explicitly set',
which triggered resolveDefaultReasoningLevel() to re-enable
reasoning for capable models (e.g. Claude Opus).

The fix persists 'off' explicitly, matching how directive-handling.persist.ts
already handles the inline /reasoning off command.

Fixes #24406
Fixes #24411

Co-authored-by: echoVic <AkiraVic@outlook.com>
2026-02-24 03:33:30 +00:00
junwon
04bcabcbae fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check (#24939)
* fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check

On Windows, `fs.lstatSync` (path-based) returns `dev: 0` while
`fs.fstatSync` (fd-based) returns the real NTFS volume serial number.
This mismatch caused `sameFileIdentity` to always fail, making
`openVerifiedFileSync` reject every file — silently breaking all
Control UI static file serving (HTTP 404).

Fall back to ino-only comparison when either dev is 0 on Windows.
ino remains unique within a single volume, so TOCTOU protection
is preserved.

Fixes #24692

* fix: format sameFileIdentity wrapping (#24939)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-24 03:33:27 +00:00
Glucksberg
a3b82a563d fix: resolve symlinks in pnpm/bun global install detection (#24744)
Use tryRealpath() instead of path.resolve() when comparing expected
package paths in detectGlobalInstallManagerForRoot(). path.resolve()
only normalizes path strings without following symlinks, causing pnpm
global installs to go undetected since pnpm symlinks node_modules
entries into its .pnpm content-addressable store.

Fixes #22768

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 03:33:24 +00:00
Glucksberg
1e23d2ecea fix(whatsapp): respect selfChatMode config in access-control (#24738)
The selfChatMode config field was resolved by accounts.ts but never
consumed in the access-control logic. Use nullish coalescing so an
explicit true/false from config takes precedence over the allowFrom
heuristic, while undefined falls back to the existing behavior.

Fixes #23788

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 03:33:21 +00:00
不做了睡大觉
ae281a6f61 fix: suppress "Run doctor --fix" hint when already in fix mode with no changes (#24666)
When running `openclaw doctor --fix` and no config changes are needed,
the else branch unconditionally showed "Run doctor --fix to apply changes"
which is confusing since we just ran --fix.

Now the hint only appears when NOT in fix mode (i.e. when running plain
`openclaw doctor`). When in fix mode with nothing to change, the command
silently proceeds to the "Doctor complete." outro.

Fixes #24566

Co-authored-by: User <user@example.com>
2026-02-24 03:33:17 +00:00
Peter Steinberger
3af9d1f8e9 fix: scope Telegram RFC2544 SSRF exception to policy opt-in (#24982) (thanks @stakeswky) 2026-02-24 03:28:00 +00:00
User
9df80b73e2 fix: allow RFC2544 benchmark range (198.18.0.0/15) through SSRF filter
Telegram's API and file servers resolve to IPs in the 198.18.0.0/15
range (RFC 2544 benchmarking range). The SSRF filter was blocking these
addresses because ipaddr.js classifies them as 'reserved', and the
filter also had an explicit RFC2544_BENCHMARK_PREFIX check that blocked
them unconditionally.

Fix: exempt 198.18.0.0/15 from the 'reserved' range block in
isBlockedSpecialUseIpv4Address(). Other 'reserved' ranges (TEST-NET-2,
TEST-NET-3, documentation prefixes) remain blocked. The explicit
RFC2544_BENCHMARK_PREFIX check is repurposed as the exemption guard.

Closes #24973
2026-02-24 03:28:00 +00:00
Ali Al Jufairi
237b9be937 chore(docs) : remove the mention of Anthropic OAuth since it is not allowed according to there new guidlines (#24989) 2026-02-24 03:23:01 +00:00
Adam
d07d24eebe fix: clamp poll sleep duration to non-negative in bash-tools process (#24889)
`Math.min(250, deadline - Date.now())` could return a negative value if
the deadline expired between the while-condition check and the setTimeout
call. Wrap with `Math.max(0, ...)` to ensure the sleep is never negative.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 03:22:58 +00:00
青雲
dc8423f2c0 fix: back up existing systemd unit before overwriting on update (#24350) (#24937)
When `openclaw update` regenerates the systemd service file, any user
customizations to ExecStart (e.g. proxychains4 wrapper) are silently
lost. Now the existing unit file is copied to `.bak` before writing
the new one, so users can restore their customizations.

The backup path is printed in the install output so users are aware.

Co-authored-by: echoVic <AkiraVic@outlook.com>
2026-02-24 03:22:55 +00:00
Soumik Bhatta
70cfb69a5f fix(doctor): skip false positive permission warnings for Nix store symlinks (#24901)
On NixOS/Nix-managed installs, config and state directories are symlinks
into /nix/store/. Symlinks on Linux always report 0o777 via lstatSync,
causing `openclaw doctor` to incorrectly warn about open permissions.

Use lstatSync to detect symlinks, resolve the target, and only suppress
the warning when the resolved path lives in /nix/store/ (an immutable
filesystem). Symlinks to insecure targets still trigger warnings.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 03:22:52 +00:00
Bill Cropper
588ad7fb38 fix: respect agent model config in slug generator (#24776)
The slug generator was using hardcoded DEFAULT_PROVIDER and DEFAULT_MODEL
instead of resolving from agent config. This caused it to fall back to
anthropic/claude-opus-4-6 even when a cloud model was configured.

Now uses resolveAgentModelPrimary() to get the configured model, with
fallback to defaults if not configured.

Fixes issue where session memory filenames would fail to generate
when using cloud models that require special backends.
2026-02-24 03:22:48 +00:00
David Murray
e2e10b3da4 fix(slack): map threadId to replyToId for restart sentinel notifications (#24885)
The restart sentinel wake path passes threadId to deliverOutboundPayloads,
but Slack requires replyToId (mapped to thread_ts) for threading. The agent
reply path already does this conversion but the sentinel path did not,
causing post-restart notifications to land as top-level DMs.

Fixes #17716
2026-02-24 03:22:45 +00:00
Omair Afzal
19c43eade2 fix(memory): strip null bytes from workspace paths causing ENOTDIR (#24876)
Add stripNullBytes() helper and apply it to all return paths in
resolveAgentWorkspaceDir() including configured, default, and
state-dir-derived paths. Null bytes in paths cause ENOTDIR errors
when Node tries to resolve them as directories.
2026-02-24 03:22:42 +00:00
Omair Afzal
177f167eab fix: guard .trim() calls on potentially undefined workspaceDir (#24875)
Change workspaceDir param type from string to string | undefined in
resolvePluginSkillDirs and use nullish coalescing before .trim() to
prevent TypeError when workspaceDir is undefined.
2026-02-24 03:22:39 +00:00
Peter Steinberger
7b2b86c60a fix(exec): add approval race changelog and regressions 2026-02-24 03:22:05 +00:00
Peter Steinberger
6f0dd61795 fix(exec): restore two-phase approval registration flow 2026-02-24 03:16:36 +00:00
Peter Steinberger
c6c1e3e7cf docs(changelog): correct exec approvals reporter credit 2026-02-24 03:13:48 +00:00
Peter Steinberger
ffd63b7a2c fix(security): trust resolved skill-bin paths in allowlist auto-allow 2026-02-24 03:12:43 +00:00
Peter Steinberger
204d9fb404 refactor(security): dedupe shell env probe and add path regression test 2026-02-24 03:11:33 +00:00
Peter Steinberger
64aab80201 test(exec): add regressions for safe-bin metadata and chain semantics 2026-02-24 03:10:19 +00:00
Peter Steinberger
a67689a7e3 fix: harden allow-always shell multiplexer wrapper handling 2026-02-24 03:06:51 +00:00
Peter Steinberger
4a3f8438e5 fix(gateway): bind node exec approvals to nodeId 2026-02-24 03:05:58 +00:00
Peter Steinberger
9530c01085 refactor(exec): split safe-bin policy modules and dedupe allowlist flow 2026-02-24 03:05:03 +00:00
Peter Steinberger
c5ac90ab92 docs(changelog): add shell-env fallback hardening note 2026-02-24 03:04:49 +00:00
Peter Steinberger
60f1d1959a test: stabilize invoke-system-run env-wrapper assertion on Windows 2026-02-24 03:02:38 +00:00
Peter Steinberger
d0ef4c75c7 docs(changelog): credit safeBins advisory reporters 2026-02-24 02:59:17 +00:00
Peter Steinberger
ff10fe8b91 fix(security): require /etc/shells for shell env fallback 2026-02-24 02:58:24 +00:00
Shakker
71f4b93656 docs: refresh clawtributors list 2026-02-24 02:55:02 +00:00
Shakker
ef1ffacfb2 scripts: exclude unresolved clawtributors from README 2026-02-24 02:55:02 +00:00
Peter Steinberger
90383e00e9 fix(security): harden autoAllowSkills exec matching 2026-02-24 02:53:47 +00:00
Peter Steinberger
e578521ef4 fix(security): harden session export image data-url handling 2026-02-24 02:53:39 +00:00
Peter Steinberger
fefc414576 fix(security): harden structural session path fallback 2026-02-24 02:52:48 +00:00
Peter Steinberger
ff4e6ca0d9 fix(ios): gate agent deep links with local confirmation 2026-02-24 02:51:58 +00:00
Peter Steinberger
f8524ec77a fix(security): harden exported session html rendering 2026-02-24 02:40:29 +00:00
Peter Steinberger
f6afc8c5b6 docs(security): clarify host-side exec trust model defaults 2026-02-24 02:40:18 +00:00
Peter Steinberger
1d28da55a5 fix(voice-call): block Twilio webhook replay and stale transitions 2026-02-24 02:37:24 +00:00
Gustavo Madeira Santana
4663d68384 Tests: make model-catalog fixtures type-valid 2026-02-23 21:36:34 -05:00
Peter Steinberger
ce02ad9643 refactor(agents): centralize sandbox media and fs policy helpers 2026-02-24 02:32:01 +00:00
Gustavo Madeira Santana
207ec7cfae chore(provider): remove unused pruning functions 2026-02-23 21:31:12 -05:00
Peter Steinberger
4032390572 docs(security): clarify trusted user-triggered local actions 2026-02-24 02:29:09 +00:00
Peter Steinberger
3f923e8313 test: add env -S allowlist bypass regressions 2026-02-24 02:28:00 +00:00
Peter Steinberger
6634030be3 fix: enforce apply_patch workspaceOnly in sandbox mounts 2026-02-24 02:23:56 +00:00
Peter Steinberger
c070be1bc4 fix(sandbox): harden fs bridge path checks and bind mount policy 2026-02-24 02:21:43 +00:00
Peter Steinberger
dd9d9c1c60 fix(security): enforce workspaceOnly for sandbox image tool 2026-02-24 02:17:55 +00:00
Peter Steinberger
0026255def refactor(security): harden system.run wrapper enforcement 2026-02-24 02:17:41 +00:00
Gustavo Madeira Santana
5239b55c0a Config: expand Kilo catalog and persist selected Kilo models (#24921)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f5a7e1a385
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 21:17:37 -05:00
Peter Steinberger
6c441ea797 fix: support legacy and beta prerelease version formats 2026-02-24 02:05:37 +00:00
Peter Steinberger
08e2aa44e7 fix(commands): restrict commands.allowFrom to sender principals 2026-02-24 02:01:01 +00:00
Peter Steinberger
223d7dc23d feat(gateway)!: require explicit non-loopback control-ui origins 2026-02-24 01:57:11 +00:00
Peter Steinberger
edfefdff7d docs(changelog): mark ACP hardening as next npm release 2026-02-24 01:56:22 +00:00
Peter Steinberger
a1c4bf07c6 fix(security): harden exec wrapper allowlist execution parity 2026-02-24 01:52:17 +00:00
Peter Steinberger
5eb72ab769 fix(security): harden browser SSRF defaults and migrate legacy key 2026-02-24 01:52:01 +00:00
Peter Steinberger
8779b523dc test(sandbox): speed up agent-config coverage with pure resolvers 2026-02-24 01:46:12 +00:00
Peter Steinberger
467666adc7 test(sandbox): use focused modules in lightweight suites 2026-02-24 01:46:12 +00:00
Peter Steinberger
f0f886ecc4 docs(security): clarify gateway-node trust boundary in docs 2026-02-24 01:35:44 +00:00
Peter Steinberger
1f81677093 docs(changelog): note dangerous name-matching audit unification 2026-02-24 01:33:08 +00:00
Peter Steinberger
161d9841dc refactor(security): unify dangerous name matching handling 2026-02-24 01:33:08 +00:00
Peter Steinberger
6a7c303dcc test(msteams): fix allowlist name-match expectations 2026-02-24 01:26:53 +00:00
Peter Steinberger
2e36bdda85 docs(changelog): credit ACP security reporter 2026-02-24 01:19:03 +00:00
Peter Steinberger
22467902ea fix(doctor): inherit dangerous name-matching flag in mutable allowlist scan 2026-02-24 01:18:38 +00:00
Peter Steinberger
e5931554bf test: tighten slow test timeouts and cleanup 2026-02-24 01:16:53 +00:00
Peter Steinberger
6c43d0a08e test(gateway): move sessions_send error paths to unit tests 2026-02-24 01:16:53 +00:00
Peter Steinberger
63dcd28ae0 fix(acp): harden permission tool-name validation 2026-02-24 01:11:34 +00:00
Peter Steinberger
f97c0922e1 fix(security): harden account-key handling against prototype pollution 2026-02-24 01:09:31 +00:00
Peter Steinberger
12cc754332 fix(acp): harden permission auto-approval policy 2026-02-24 01:03:30 +00:00
Peter Steinberger
ddf93d9845 docs(security): add vps trust-boundary guidance 2026-02-24 01:02:11 +00:00
Peter Steinberger
cfa44ea6b4 fix(security): make allowFrom id-only by default with dangerous name opt-in (#24907)
* fix(channels): default allowFrom to id-only; add dangerous name opt-in

* docs(security): align channel allowFrom docs with id-only default
2026-02-24 01:01:51 +00:00
Peter Steinberger
41b0568b35 docs(security): clarify shared-agent trust boundaries 2026-02-24 01:00:05 +00:00
Peter Steinberger
0cc327546b test(gateway): speed up slow e2e test setup 2026-02-24 00:59:52 +00:00
Peter Steinberger
13478cc79a refactor(config): harden catchall hint mapping and array fallback 2026-02-24 00:59:44 +00:00
Vincent Koc
30c622554f Providers: disable developer role for DashScope-compatible endpoints (#24675)
* Agents: disable developer role for DashScope-compatible endpoints

* Agents: test DashScope developer-role compatibility

* Gateway: test allowlisted sessions.patch model selection

* Changelog: add DashScope role-compat fix note
2026-02-23 19:51:16 -05:00
Peter Steinberger
83eae14ed6 docs: add security-advisory triage reminder to agents guide 2026-02-24 00:45:41 +00:00
Peter Steinberger
400220275c docs: clarify multi-instance recommendations for user isolation 2026-02-24 00:40:08 +00:00
Peter Steinberger
a430e1722b test(channels): reduce media test runtime and polling 2026-02-24 00:31:58 +00:00
Peter Steinberger
663f784e4e test(core): trim redundant setup and tighten waits 2026-02-24 00:31:58 +00:00
Peter Steinberger
f58c1ef34e test(gateway): speed up contract and polling suites 2026-02-24 00:31:58 +00:00
Peter Steinberger
7d55277d72 docs: clarify operator trust boundary for shared gateways 2026-02-24 00:25:01 +00:00
Peter Steinberger
f0c3c8b6a3 fix(config): redact dynamic catchall secret keys 2026-02-24 00:21:29 +00:00
Peter Steinberger
8dfa33d373 test(sandbox): add root bind mount regression 2026-02-24 00:17:21 +00:00
Peter Steinberger
d68380bb7f docs(security): clarify exposed-secret report scope 2026-02-24 00:17:21 +00:00
Peter Steinberger
25f6fcc63a docs(changelog): note safeBins exec hardening 2026-02-23 23:58:58 +00:00
Peter Steinberger
3b8e33037a fix(security): harden safeBins long-option validation 2026-02-23 23:58:58 +00:00
Peter Steinberger
7b4d2cb5cb docs(security): clarify trusted-config dos scope 2026-02-23 23:57:26 +00:00
Peter Steinberger
a2dfe9879f fix(security): harden regex compilation for filters and redaction 2026-02-23 23:54:50 +00:00
Peter Steinberger
e6484cb65f refactor: harden kilocode auth ordering and dedupe provider wiring 2026-02-23 23:37:13 +00:00
Peter Steinberger
f52a0228ca test: optimize auth and audit test runtime 2026-02-23 23:31:52 +00:00
John Fawcett
13f32e2f7d feat: Add Kilo Gateway provider (#20212)
* feat: Add Kilo Gateway provider

Add support for Kilo Gateway as a model provider, similar to OpenRouter.
Kilo Gateway provides a unified API that routes requests to many models
behind a single endpoint and API key.

Changes:
- Add kilocode provider option to auth-choice and onboarding flows
- Add KILOCODE_API_KEY environment variable support
- Add kilocode/ model prefix handling in model-auth and extra-params
- Add provider documentation in docs/providers/kilocode.md
- Update model-providers.md with Kilo Gateway section
- Add design doc for the integration

* kilocode: add provider tests and normalize onboard auth-choice registration

* kilocode: register in resolveImplicitProviders so models appear in provider filter

* kilocode: update base URL from /api/openrouter/ to /api/gateway/

* docs: fix formatting in kilocode docs

* fix: address PR review — remove kilocode from cacheRetention, fix stale model refs and CLI name in docs, fix TS2742

* docs: fix stale refs in design doc — Moltbot to OpenClaw, MoltbotConfig to OpenClawConfig, remove extra-params section, fix doc path

* fix: use resolveAgentModelPrimaryValue for AgentModelConfig union type

---------

Co-authored-by: Mark IJbema <mark@kilocode.ai>
2026-02-23 23:29:27 +00:00
Peter Steinberger
ddb7ec99a8 test: speed up cron test polling and waits 2026-02-23 22:42:23 +00:00
Peter Steinberger
0cc46d774c test: consolidate auth-choice tests for faster coverage 2026-02-23 22:42:23 +00:00
Gustavo Madeira Santana
eff3c5c707 Session/Cron maintenance hardening and cleanup UX (#24753)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7533b85156
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-02-23 22:39:48 +00:00
Peter Steinberger
29b19455e3 test(commands): collapse provider and endpoint matrices 2026-02-23 22:16:45 +00:00
Peter Steinberger
b922ecb8c1 test(security): reduce duplicate audit assertions 2026-02-23 22:16:39 +00:00
Peter Steinberger
cd5f3fe0c1 test(config): consolidate env/include scenario coverage 2026-02-23 22:16:30 +00:00
Peter Steinberger
c248c515a3 test: collapse sandbox agent config duplicate cases 2026-02-23 22:01:32 +00:00
Peter Steinberger
287586206c test: consolidate sandbox docker merge scenarios 2026-02-23 22:01:22 +00:00
Peter Steinberger
8b192beaaf test: combine web reconnect progression assertions 2026-02-23 21:57:30 +00:00
Peter Steinberger
ecd278b67b test: merge redundant telegram media path scenarios 2026-02-23 21:57:23 +00:00
Peter Steinberger
ca761d6225 test: consolidate gateway auth test scenarios 2026-02-23 21:57:17 +00:00
Peter Steinberger
b9f01e8d3f test: consolidate directive behavior suites for faster runs 2026-02-23 21:48:12 +00:00
Peter Steinberger
b8fc8e7e6d test: optimize directive behavior test scenarios 2026-02-23 21:35:42 +00:00
Peter Steinberger
0183610db3 refactor: de-duplicate channel runtime and payload helpers 2026-02-23 21:25:28 +00:00
Peter Steinberger
0ae7f470a2 test: normalize skill prompt path assertions on windows 2026-02-23 21:17:29 +00:00
Peter Steinberger
31ca7fb277 test: consolidate directive behavior test scenarios 2026-02-23 21:13:11 +00:00
Peter Steinberger
426f803b8a test: speed up sessions_spawn tool harness 2026-02-23 21:13:05 +00:00
Peter Steinberger
7e5f771d27 test: speed up skills test suites 2026-02-23 21:02:13 +00:00
Peter Steinberger
75423a00d6 refactor: deduplicate shared helpers and test setup 2026-02-23 20:40:44 +00:00
Peter Steinberger
1f5e6444ee test: remove redundant pi embedded runner cases 2026-02-23 20:15:56 +00:00
Peter Steinberger
3b5a276a48 test: speed up supervisor test timing 2026-02-23 20:15:56 +00:00
Peter Steinberger
5a475259bb fix(telegram): suppress reasoning-only leaks when reasoning is off
Co-authored-by: avirweb <avirweb@users.noreply.github.com>
2026-02-23 20:06:16 +00:00
Peter Steinberger
63e4dfaa9c test: consolidate pi-tools gating assertions 2026-02-23 20:00:11 +00:00
Peter Steinberger
cba8037d90 test: prune redundant trigger handling integration coverage 2026-02-23 20:00:11 +00:00
Peter Steinberger
32e6ccb7b6 test(cron): cover announce failure when best-effort is off 2026-02-23 19:48:37 +00:00
Peter Steinberger
9af3ec92a5 fix(gateway): add HSTS header hardening and docs 2026-02-23 19:47:29 +00:00
Peter Steinberger
c88915b721 test: consolidate trigger handling suites 2026-02-23 19:41:47 +00:00
Peter Steinberger
87603b5c45 fix: sync built-in channel enablement across config paths 2026-02-23 19:40:42 +00:00
Peter Steinberger
69b17a37e8 docs(reference): add cache trace diagnostics knobs to prompt-caching guide 2026-02-23 19:39:35 +00:00
Peter Steinberger
7a40d99b1d refactor(cron): extract delivery dispatch + harden reset notices 2026-02-23 19:25:22 +00:00
Peter Steinberger
fe62711342 test(gate): stabilize env- and timing-sensitive process/web-search checks 2026-02-23 19:19:58 +00:00
Peter Steinberger
46dee26600 docs(reference): add prompt-caching guide and knobs
Co-authored-by: Axel Svensson <svenssonaxel@users.noreply.github.com>
2026-02-23 19:19:45 +00:00
Peter Steinberger
31e4c21b67 fix(auto-reply): move volatile inbound flags out of system metadata
Co-authored-by: aidiffuser <aidiffuser@users.noreply.github.com>
2026-02-23 19:19:45 +00:00
Peter Steinberger
cf38339f25 fix(tools): improve session_status cache-aware usage reporting
Co-authored-by: Lucian Feraru <1ucian@users.noreply.github.com>
2026-02-23 19:19:45 +00:00
Peter Steinberger
40db3fef49 fix(agents): cache bootstrap snapshots per session key
Co-authored-by: Isis Anisoptera <github@lotuswind.net>
2026-02-23 19:19:45 +00:00
Nimrod Gutman
8b3eee71ec fix: tier local vitest worker defaults by host memory (#24719) (thanks @ngutman) 2026-02-23 21:19:21 +02:00
Nimrod Gutman
420c18364e fix(test): tier local vitest worker defaults by host memory 2026-02-23 21:19:21 +02:00
Peter Steinberger
2931e215ca docs: add GitHub comment formatting/linking guardrails 2026-02-23 19:17:33 +00:00
Peter Steinberger
47723b646d refactor(test): de-duplicate msteams and bash test helpers 2026-02-23 19:12:27 +00:00
Ruslan Kharitonov
8d69251475 fix(doctor): use gateway health status for memory search key check (#22327)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 2f02ec9403
Co-authored-by: therk <901920+therk@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 14:07:16 -05:00
Peter Steinberger
bf373eeb43 refactor: harden reset notice + cron delivery target flow 2026-02-23 19:01:02 +00:00
Peter Steinberger
d266d12be1 refactor(exec): simplify env-prefixed wrapper modifier check 2026-02-23 18:56:14 +00:00
Peter Steinberger
42373b6742 fix(skills): support multiline frontmatter fallback without PyYAML 2026-02-23 18:56:14 +00:00
Brian Mendonca
f18f087c3c fix(skills): make quick_validate work without PyYAML
(cherry picked from commit 485a55b4ec)
2026-02-23 18:56:14 +00:00
Brian Mendonca
bd8b9af9a7 fix(exec): bind env-prefixed shell wrappers to full approval text
(cherry picked from commit 1edf957988)
2026-02-23 18:56:14 +00:00
oneaix
216d99e585 fix(browser): derive relay auth token from gateway token in Chrome extension
The extension relay server authenticates using an HMAC-SHA256 derived
token (`openclaw-extension-relay-v1:<port>`), but the Chrome extension
was sending the raw gateway token. This caused both the WebSocket
connection and the options page validation to fail with 401 Unauthorized.

Additionally, the options page validation request triggered a CORS
preflight (due to the custom `x-openclaw-relay-token` header) which the
relay rejects because OPTIONS requests lack auth headers. The options
page now delegates the check to the background service worker which has
host_permissions and bypasses CORS preflight.

Fixes #23842

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit bbc654b9f0)
2026-02-23 18:56:14 +00:00
Mustafa Kemal
bb8f538cd4 Browser relay: accept raw gateway token in extension auth
(cherry picked from commit e682a768d0)
2026-02-23 18:56:14 +00:00
justinhuangcode
d00d814ad1 fix(gateway): include platform and reason in node command rejection error
The generic "node command not allowed" error gives no indication of why the
command was rejected, making it hard to diagnose issues (e.g. running
`nodes notify` against a Linux node that does not declare `system.notify`).

Include the rejection reason and node platform in the error message so
callers can tell whether the command is not supported by the node, not in
the platform allowlist, or the node did not advertise its capabilities.

Fixes #24616

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit e3d74619bc)
2026-02-23 18:56:14 +00:00
Gustavo Madeira Santana
5de1f540e7 CLI: fix gateway restart health ownership for child listener pids (#24696)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: d6d4b43f7e
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 13:53:10 -05:00
Peter Steinberger
78e7f41d28 docs: detail per-agent prompt caching configuration 2026-02-23 18:46:40 +00:00
Peter Steinberger
d637fd4801 fix(config): tighten bedrock cache-retention type narrowing 2026-02-23 18:46:40 +00:00
Peter Steinberger
160bd61fff feat(agents): add per-agent stream params overrides for cache tuning (#17470) (thanks @rrenamed) 2026-02-23 18:46:40 +00:00
Peter Steinberger
be6f0b8c84 fix(providers): support Bedrock Anthropic cacheRetention defaults/pass-through (#22303) (thanks @snese) 2026-02-23 18:46:40 +00:00
Peter Steinberger
ca5c0bc02b fix(providers): disable Bedrock prompt caching for non-Anthropic models (#20866) (thanks @pierreeurope) 2026-02-23 18:46:40 +00:00
Peter Steinberger
e40ee3c2c7 docs(changelog): note /new and /reset auth-label removal (#24409) 2026-02-23 18:30:30 +00:00
Peter Steinberger
b9b77cea4e fix(reply): omit auth labels in /new and /reset 2026-02-23 18:30:30 +00:00
Peter Steinberger
4c21ef9ce9 docs(changelog): correct kimi issue references 2026-02-23 18:28:56 +00:00
Peter Steinberger
ff0c40d367 test(tools): fix kimi web_search mock typing 2026-02-23 18:27:37 +00:00
Peter Steinberger
7837d23103 feat(media): add moonshot video provider and wiring
Co-authored-by: xiaoyaner0201 <xiaoyaner0201@users.noreply.github.com>
2026-02-23 18:27:37 +00:00
Peter Steinberger
e02c470d5e feat(tools): add kimi web_search provider
Co-authored-by: adshine <adshine@users.noreply.github.com>
2026-02-23 18:27:37 +00:00
Peter Steinberger
f93ca93498 fix(agents): extend cache-ttl eligibility for moonshot and zai
Co-authored-by: lailoo <lailoo@users.noreply.github.com>
2026-02-23 18:27:36 +00:00
Peter Steinberger
2fa6aa6ea6 test(agents): add comprehensive kimi regressions 2026-02-23 18:27:36 +00:00
Doruk Ardahan
daaad03593 fix(infra): treat nested network request errors as non-fatal 2026-02-23 18:27:23 +00:00
Peter Steinberger
445c7a65e6 test: simplify session reset and rawbody coverage 2026-02-23 18:19:23 +00:00
Peter Steinberger
783a9134d6 test: prune redundant trigger-handling scenarios 2026-02-23 18:19:23 +00:00
chilu18
3cadc3eed1 fix(plugins): honor channels.<id>.enabled for bundled channels 2026-02-23 18:16:58 +00:00
Peter Steinberger
65d57eac12 docs(changelog): reorder 2026.2.23 entries by user impact 2026-02-23 18:02:21 +00:00
Peter Steinberger
97787d73c2 docs(changelog): align 2026.2.22 release heading with tags 2026-02-23 18:00:39 +00:00
Peter Steinberger
cc7a498ace refactor(tests): deduplicate repeated fixtures in msteams and bash tests 2026-02-23 17:59:56 +00:00
Peter Steinberger
b81bce703c test: streamline trigger and session coverage 2026-02-23 17:52:23 +00:00
Peter Steinberger
ddc67aa4ef test: collapse duplicate trigger command coverage 2026-02-23 17:37:13 +00:00
Vincent Koc
6a0fcf6518 Sessions: consolidate path hardening and fallback resilience (#24657)
* Changelog: credit session path fixes

* Sessions: harden path resolution for symlink and stale metadata

* Tests: cover fallback for invalid absolute sessionFile

* Tests: add symlink alias session path coverage

* Tests: guard symlink escape in sessionFile resolution
2026-02-23 12:36:01 -05:00
Matthew
ce1f12ff33 fix(slack): prevent Zod default groupPolicy from breaking multi-account config (#17579)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7d2da57b50
Co-authored-by: ZetiMente <76985631+ZetiMente@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 12:35:41 -05:00
Vincent Koc
f03ff39754 Providers: skip context1m beta for Anthropic OAuth tokens (#24620)
* Providers: skip context1m beta for Anthropic OAuth tokens

* Tests: cover OAuth context1m beta skip behavior

* Docs: note context1m OAuth incompatibility

* Agents: add context1m-aware context token resolver

* Agents: cover context1m context-token resolver

* Commands: apply context1m-aware context tokens in session store

* Commands: apply context1m-aware context tokens in status summary

* Status: resolve context tokens with context1m model params

* Status: test context1m status context display
2026-02-23 12:29:09 -05:00
Gustavo Madeira Santana
28377e1b7a UI: add version status pill before Health in web header (#24648)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f240589d33
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 12:27:17 -05:00
Peter Steinberger
fdd185cfaa test: merge inline trigger command and elevated coverage 2026-02-23 17:19:39 +00:00
Peter Steinberger
f7e45ce947 test: consolidate trigger-handling status and heartbeat scenarios 2026-02-23 17:19:39 +00:00
Peter Steinberger
a8a4fa5b88 test: de-duplicate attachment and bash tool tests 2026-02-23 17:19:34 +00:00
Vincent Koc
ae66a4b5d2 Changelog: add PR #22855 entry 2026-02-23 12:15:50 -05:00
Vincent Koc
5e1dd5fe69 Changelog: add PR #24593 entry 2026-02-23 12:15:50 -05:00
Vincent Koc
d601392904 Changelog: add PR #16176 entry 2026-02-23 12:15:50 -05:00
Shakker
271a149058 chore: add skills-lock.json to gitignore 2026-02-23 17:12:39 +00:00
Sally O'Malley
eb4ff6df81 Allow Claude model requests to route through Google Vertex AI (#23985)
* feat: add anthropic-vertex provider for Claude via GCP Vertex AI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: sallyom <somalley@redhat.com>

* docs: add anthropic-vertex provider guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: sallyom <somalley@redhat.com>

* Agents: validate Anthropic Vertex project env

* Changelog: format update for Vertex entry

* Providers: rename Anthropic Vertex to Google Vertex Claude

* Providers: remove Vertex Claude provider path

* Models: normalize Vercel Claude shorthand refs

* Onboarding: default Vercel model to Claude shorthand

* Changelog: add @vincentkoc credit for #23985

* Onboarding: keep canonical Vercel default model ref

* Tests: expand Vercel model normalization coverage

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 11:04:31 -05:00
Clawborn
544809b6f6 Add Chinese context overflow patterns to isContextOverflowError (#22855)
Proxy providers returning Chinese error messages (e.g. Chinese LLM
gateways) use patterns like '上下文过长' or '上下文超出' that are not
matched by the existing English-only patterns in isContextOverflowError.
This prevents auto-compaction from triggering, leaving the session stuck.

Add the most common Chinese proxy patterns:
- 上下文过长 (context too long)
- 上下文超出 (context exceeded)
- 上下文长度超 (context length exceeds)
- 超出最大上下文 (exceeds maximum context)
- 请压缩上下文 (please compress context)

Chinese characters are unaffected by toLowerCase() so check the
original message directly.

Closes #22849
2026-02-23 10:54:24 -05:00
Vincent Koc
4f340b8812 fix(agents): avoid classifying reasoning-required errors as context overflow (#24593)
* Agents: exclude reasoning-required errors from overflow detection

* Tests: cover reasoning-required overflow classification guard

* Tests: format reasoning-required endpoint errors
2026-02-23 10:38:49 -05:00
Alice Losasso
652099cd5c fix: correctly identify Groq TPM limits as rate limits instead of context overflow (#16176)
Co-authored-by: Howard <dddabtc@users.noreply.github.com>
2026-02-23 10:32:53 -05:00
LI SHANXIN
c1b75ab8e2 fix(telegram): make reaction handling soft-fail and message-id resilient (#20236)
* Telegram: soft-fail reactions and fallback to inbound message id

* Telegram: soft-fail missing reaction message id

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 10:25:14 -05:00
DukeDeSouth
ea47ab29bd fix: cancel compaction instead of truncating history when summarization fails (#10711)
* fix: cancel compaction instead of truncating history when summarization fails

When the compaction safeguard cannot generate a summary (no model, no API
key, or LLM error), it previously returned a "Summary unavailable" fallback
string and still truncated history. This caused irreversible data loss -
older messages were discarded even though no meaningful summary was produced.

Now returns `{ cancel: true }` in all three failure paths so the framework
aborts compaction entirely and preserves the full conversation history.

Fixes #10332

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: use deterministic timestamps in compaction safeguard tests

Replace Date.now() with fixed timestamp (0) in test data to prevent
nondeterministic behavior in snapshot-based or order-dependent tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Changelog: note compaction cancellation safeguard fix

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 10:23:13 -05:00
Owen
01380f49f5 fix(compaction): pass model through runtime for safeguard summaries (#17864)
* fix(compaction): pass model through runtime to fix ctx.model undefined

Fixes #3479

Root cause: extensionRunner.initialize() is never called in compact.ts workflow,
leaving ctx.model undefined. Compaction safeguard checks ctx.model and returns
fallback summary immediately without attempting LLM summarization.

Changes:
1. Pass model through compaction safeguard runtime registry (same pattern as maxHistoryShare)
2. Fall back to runtime.model when ctx.model is undefined
3. Add once-per-session warning when both models are missing (prevents log spam)
4. Add regression test for runtime.model fallback

This follows the established runtime registry pattern rather than attempting to call
extensionRunner.initialize() (which is SDK-internal and not meant for direct access).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: add comprehensive tests for compaction-safeguard model fallback

Add integration tests to verify the model fallback behavior:
- Test runtime.model fallback when ctx.model is undefined (compact.ts workflow)
- Test fallback summary when both ctx.model and runtime.model are undefined
- Test contextWindowTokens runtime storage/retrieval
- Test combined runtime values (maxHistoryShare + contextWindowTokens + model)

These tests verify the fix for issue #3479 where compaction fails due to
ctx.model being undefined in the compact.ts workflow. The runtime registry
pattern allows model to be passed when extensionRunner.initialize() is not
called, ensuring summarization works in all code paths.

Related: PR #17864

* fix(test): adapt compaction-safeguard tests to upstream type changes

- Add baseUrl to Model mock objects (now required by Model<Api>)
- Add explicit Model<Api> annotation to prevent provider string widening
- Cast modelRegistry mock through unknown (ModelRegistry expanded)
- Use non-null assertion for compactionHandler (TypeScript strict)
- Type compaction result explicitly

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

* Compaction: add changelog credit for model fallback fix

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 10:14:21 -05:00
青雲
69692d0d3a fix: detect additional context overflow error patterns to prevent leak to user (#20539)
* fix: detect additional context overflow error patterns to prevent leak to user

Fixes #9951

The error 'input length and max_tokens exceed context limit: 170636 +
34048 > 200000' was not caught by isContextOverflowError() and leaked
to users via formatAssistantErrorText()'s invalidRequest fallback.

Add three new patterns to isContextOverflowError():
- 'exceed context limit' (direct match)
- 'exceeds the model\'s maximum context'
- max_tokens/input length + exceed + context (compound match)

These are now rewritten to the friendly context overflow message.

* Overflow: add regression tests and changelog credits

* Update CHANGELOG.md

* Update pi-embedded-helpers.isbillingerrormessage.test.ts

---------

Co-authored-by: echoVic <AkiraVic@outlook.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 10:03:56 -05:00
Vincent Koc
7fb69b7cd2 Gateway: stop repeated unauthorized WS request floods per connection (#24294)
* Gateway WS: add unauthorized flood guard primitive

* Gateway WS: close repeated unauthorized post-handshake request floods

* Gateway WS: test unauthorized flood guard behavior

* Changelog: note gateway WS unauthorized flood guard hardening

* Update CHANGELOG.md
2026-02-23 09:58:47 -05:00
Vincent Koc
8e821a061c fix(telegram): scope polling offsets per bot and await shared runner stop (#24549)
* Telegram: scope polling offsets and await shared runner stop

* Changelog: remove unrelated session-fix entries from PR

* Update CHANGELOG.md
2026-02-23 09:43:47 -05:00
AkosCz
3a3c2da916 [Feature]: Add Gemini (Google Search grounding) as web_search provider (#13075)
* feat: add Gemini (Google Search grounding) as web_search provider

Add Gemini as a fourth web search provider alongside Brave, Perplexity,
and Grok. Uses Gemini's built-in Google Search grounding tool to return
search results with citations.

- Add runGeminiSearch() with Google Search grounding via tools API
- Resolve Gemini's grounding redirect URLs to direct URLs via parallel
  HEAD requests (5s timeout, graceful fallback)
- Add Gemini config block (apiKey, model) with env var fallback
- Default model: gemini-2.5-flash (fast, cheap, grounding-capable)
- Strip API key from error messages for security
- Add config validation tests for Gemini provider
- Update docs/tools/web.md with Gemini provider documentation

Closes #13074

* feat: auto-detect search provider from available API keys

When no explicit provider is configured, resolveSearchProvider now
checks for available API keys in priority order (Brave → Gemini →
Perplexity → Grok) and selects the first provider with a valid key.

- Add auto-detection logic using existing resolve*ApiKey functions
- Export resolveSearchProvider via __testing_provider for tests
- Add 8 tests covering auto-detection, priority order, and explicit override
- Update docs/tools/web.md with auto-detection documentation

* fix: merge __testing exports, downgrade auto-detect log to debug

* fix: use defaultRuntime.log instead of .debug (not in RuntimeEnv type)

* fix: mark gemini apiKey as sensitive in zod schema

* fix: address Greptile review — add externalContent to Gemini payload, add Gemini/Grok entries to schema labels/help, remove dead schema-fields.ts

* fix(web-search): add JSON parse guard for Gemini API responses

Addresses Greptile review comment: add try/catch to handle non-JSON
responses from Gemini API gracefully, preventing runtime errors on
malformed responses.

Note: FIELD_HELP entries for gemini.apiKey and gemini.model were
already present in schema.help.ts, and gemini.apiKey was already
marked as sensitive in zod-schema.agent-runtime.ts (both fixed in
earlier commits).

* fix: use structured readResponseText result in Gemini error path

readResponseText returns { text, truncated, bytesRead }, not a string.
The Gemini error handler was using the result object directly, which
would always be truthy and never fall through to res.statusText.
Align with Perplexity/xAI/Brave error patterns.

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

* style: fix import order and formatting after rebase onto main

* Web search: send Gemini API key via header

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 09:30:51 -05:00
Peter Steinberger
3f03cdea56 test: optimize redundant suites for faster runtime 2026-02-23 13:57:34 +00:00
Peter Steinberger
5196565f19 test: reduce trigger test redundancy and speed up model coverage 2026-02-23 13:41:47 +00:00
Nimrod Gutman
9d37654a90 fix(agents): gate auto reasoning by effective thinking level (openclaw#24335) thanks @Kay-051 2026-02-23 15:38:08 +02:00
Kay-051
42795b87a3 fix(agents): don't auto-enable reasoning when thinking is active (#24290)
When thinking is set (e.g. thinking=low), the model produces internal
thinking blocks. The reasoning auto-default (based on model capability)
was formatting these blocks as "Reasoning:" text and delivering them to
WhatsApp/Telegram, leaking internal content to users.

Skip auto-enabling reasoning when thinkLevel is already set — the two
features serve the same purpose and enabling both causes the model's
internal thinking to be exposed as visible chat messages.

Users who explicitly set /reasoning on still get reasoning output.

Closes #24290

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 15:38:08 +02:00
Peter Steinberger
89a4695020 test: consolidate shard tests for faster trigger/directive suites 2026-02-23 13:30:47 +00:00
Peter Steinberger
67bccc1fa0 test: merge allow-from trigger shard and dedupe inline cases 2026-02-23 13:18:03 +00:00
Peter Steinberger
f6ee1c99a7 test: merge thinking and queue directive shards 2026-02-23 13:11:39 +00:00
Peter Steinberger
c9fbcf39ee test: merge fuzzy model directive shards 2026-02-23 13:08:30 +00:00
Peter Steinberger
e048ed1efd test: merge elevated allowlist directive shard 2026-02-23 13:05:39 +00:00
Peter Steinberger
706c9ec729 test: consolidate directive behavior suites 2026-02-23 13:02:56 +00:00
Peter Steinberger
fbdb1b3e73 test: merge elevated status directive shards 2026-02-23 12:57:39 +00:00
Peter Steinberger
b11ff9f7dd test: collapse directive behavior shards 2026-02-23 12:54:52 +00:00
Peter Steinberger
be422a9d18 test: merge model picker tests into native command suite 2026-02-23 12:50:08 +00:00
Peter Steinberger
9757d2bb64 fix(agents): normalize strict openai-compatible turn ordering
Co-authored-by: liuwenyong1985 <48443240+liuwenyong1985@users.noreply.github.com>
2026-02-23 12:44:23 +00:00
Peter Steinberger
15e32c7341 fix(models): refresh Moonshot Kimi vision capabilities
Co-authored-by: manikv12 <mac1317@live.missouristate.edu>
2026-02-23 12:44:23 +00:00
Peter Steinberger
9bd04849ed fix(agents): detect Kimi model-token-limit overflows
Co-authored-by: Danilo Falcão <danilo@falcao.org>
2026-02-23 12:44:23 +00:00
Peter Steinberger
3640484e28 fix(agents): map Moonshot developer role compatibility
Co-authored-by: Sheng-Fu Chuang <sedernet@gmail.com>

# Conflicts:
#	CHANGELOG.md
2026-02-23 12:44:23 +00:00
Julia HeySalad
8897c9d53a ci: install pyyaml in skills-python job 2026-02-23 10:44:18 +00:00
Ayaan Zaidi
2247b81219 fix(auto-reply): hide direct-chat metadata without sender-id sentinel (openclaw#24373) thanks @jd316
Co-authored-by: jd316 <138361777+jd316@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
2026-02-23 15:25:31 +05:30
Gustavo Madeira Santana
c92c3ad224 Tests: isolate quick_validate stub and remove DS_Store 2026-02-23 03:25:37 -05:00
边黎安
a4c373935f fix(agents): fall back to agents.defaults.model when agent has no model config (#24210)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 0f272b1027
Co-authored-by: bianbiandashen <16240681+bianbiandashen@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 03:18:55 -05:00
Vincent Koc
db32677f1d Update CHANGELOG.md 2026-02-23 03:03:06 -05:00
taw0002
3c57bf4c85 fix: treat HTTP 502/503/504 as failover-eligible (timeout reason) (#21017)
* fix: treat HTTP 502/503/504 as failover-eligible (timeout reason)

When a model API returns 502 Bad Gateway, 503 Service Unavailable, or
504 Gateway Timeout, the error object carries the status code directly.
resolveFailoverReasonFromError() only checked 402/429/401/403/408/400,
so 5xx server errors fell through to message-based classification which
requires the status code to appear at the start of the error message.

Many API SDKs (Google, Anthropic) set err.status = 503 without prefixing
the message with '503', so the message classifier never matched and
failover never triggered — the run retried the same broken model.

Add 502/503/504 to the status-code branch, returning 'timeout' (matching
the existing behavior of isTransientHttpError in the message classifier).

Fixes #20999

* Changelog: add failover 502/503/504 note with credits

* Failover: classify HTTP 504 as transient in message parser

* Changelog: credit taw0002 and vincentkoc for failover fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 03:01:57 -05:00
Vincent Koc
07edadfa8a skill-creator: reject unclosed YAML frontmatter (#24289) 2026-02-23 02:51:51 -05:00
Frank Yang
f208518cb9 fix(config): keep write inputs immutable when using unsetPaths (#24134)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 951f8480c3
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 02:51:13 -05:00
Vincent Koc
0e28e50b45 fix(security): detect obfuscated commands that bypass allowlist filters (#24287)
* security(exec): add obfuscated command detector

* test(exec): cover obfuscation detector patterns

* security(exec): enforce obfuscation approval on gateway host

* security(exec): enforce obfuscation approval on node host

* test(exec): prevent obfuscation timeout bypass

* chore(changelog): credit obfuscation security fix
2026-02-23 02:50:06 -05:00
Henry Loenwind
7568ae52ce Typo (#24288) 2026-02-23 02:47:06 -05:00
Henry Loenwind
ad666c5f37 Fixed Discord channel name (#24281) 2026-02-23 02:37:45 -05:00
Vincent Koc
c8a62e1cea Skills/Python: harden script edge cases and add regression tests (#24277)
* Skill creator: skip self-including .skill output

* Skill creator tests: cover output-dir-inside-skill case

* Skill validator: parse frontmatter robustly across newlines

* Skill validator tests: add CRLF and malformed frontmatter coverage

* Model usage: require positive --days value

* Model usage tests: cover --days validation and filtering

* Nano banana: close input image handles after loading

* Skill validator: keep type hints compatible with older python

* Changelog: credit @vincentkoc for Python skills hardening
2026-02-23 02:34:23 -05:00
Glucksberg
36400df086 fix: pass agentDir to /compact command for agent-specific auth (#24133)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4bb10ca78c
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 02:33:35 -05:00
Vignesh Natarajan
9ea740afb6 Sessions: canonicalize mixed-case session keys 2026-02-22 23:27:08 -08:00
Vincent Koc
1be8897339 Security: enforce pre-commit security checks in hooks and CI (#24265)
* chore(pre-commit): add security audit hooks

* ci(security): enforce security hooks in ci

* docs(changelog): add security hooks and ci attribution
2026-02-23 02:20:00 -05:00
Vincent Koc
4ab4754bdf chore(changelog): credit skill packager hardening follow-up 2026-02-23 02:09:05 -05:00
Vincent Koc
844924cf8d fix(skill-creator): harden skill packaging path handling (#24260)
* fix(skill-creator): skip symlinks during skill packaging

* test(skill-creator): cover symlink skipping and root-escape guard
2026-02-23 02:07:36 -05:00
Vignesh Natarajan
8d9d01447e chore: align plugin versions and harden outbound cross-provider test 2026-02-22 23:04:17 -08:00
Vignesh Natarajan
5a0eb695fa chore: format pre-commit config for CI 2026-02-22 22:54:44 -08:00
Vincent Koc
76dabd5214 CI/Skills: add Python lint and test harness for skills scripts (#24246)
* CI: add skills Python checks job

* Chore: add Python lint and test pre-commit hooks

* Tests: fix skill-creator package test import path

* Chore: add Python tooling config for skills scripts

* CI: run all skills Python tests

* Chore: run all skills Python tests in pre-commit

* Chore: enable pytest discovery for all skills tests

* Changelog: note skills Python quality harness
2026-02-23 01:52:00 -05:00
Vignesh Natarajan
de96f5fed2 CLI/Sessions: honor default agent for implicit store path 2026-02-22 22:47:10 -08:00
Vincent Koc
af4330ef75 Update CHANGELOG.md 2026-02-23 01:44:53 -05:00
Misha Kolesnik
ec1bc41cf2 fix(openrouter): remove conflicting reasoning_effort from payload (#24120)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: cc8ef4bb05
Co-authored-by: tenequm <22403766+tenequm@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-23 01:41:29 -05:00
CornBrother0x
f3adf142c1 fix(security): escape user input in HTML gallery to prevent stored XSS (#16958)
* Security/openai-image-gen: escape HTML gallery user input

* Tests/openai-image-gen: add gallery XSS regression coverage

* Changelog: add openai-image-gen XSS hardening note

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 01:39:58 -05:00
Vignesh Natarajan
5ad5ea53cd Agent: resolve resumed session agent scope before run 2026-02-22 22:38:05 -08:00
brandonwise
7fab4d128a fix(security): redact sensitive data in OTEL log exports (CWE-532) (#18182)
* fix(security): redact sensitive data in OTEL log exports (CWE-532)

The diagnostics-otel plugin exports ALL application logs to external
OTLP collectors without filtering. This leaks API keys, tokens, and
other sensitive data to third-party observability platforms.

Changes:
- Export redactSensitiveText from plugin-sdk for extension use
- Apply redaction to log messages before OTEL export
- Apply redaction to string attribute values
- Add tests for API key and token redaction

The existing redactSensitiveText function handles common patterns:
- API keys (sk-*, ghp_*, gsk_*, AIza*, etc.)
- Bearer tokens
- PEM private keys
- ENV-style assignments (KEY=value)
- JSON credential fields

Fixes #12542

* fix: also redact error/reason in trace spans

Address Greptile feedback:
- Redact evt.error in webhook.error span attributes and status
- Redact evt.reason in message.processed span attributes
- Redact evt.error in message.processed span status

* fix: handle undefined evt.error in type guard

* fix: redact session.state reason in OTEL metrics

Addresses Greptile feedback - session.state reason field now goes
through redactSensitiveText() like message.processed reason.

* test(diagnostics-otel): update service context for stateDir API change

* OTEL diagnostics: redact sensitive values before export

* OTEL diagnostics tests: cover message, attribute, and session reason redaction

* Changelog: note OTEL sensitive-data redaction fix

* Changelog: move OTEL redaction entry to current unreleased

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-23 01:35:32 -05:00
Ayaan Zaidi
61db3d4a16 fix(protocol): regenerate swift gateway models 2026-02-23 11:52:42 +05:30
Ayaan Zaidi
86fcca2352 fix(gateway): annotate connection test mocks 2026-02-23 11:47:27 +05:30
Ayaan Zaidi
fda98f5605 docs(changelog): add telegram topic target fix 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
d5105ca456 fix(telegram): unify topic target normalization path 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
fddc60d174 fix(telegram): preserve legacy prefixed messaging targets 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
bf732b88e7 test(cron): avoid delivery.mode type widening in isolated announce test 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
118611465c test(gateway): make strict-delivery bestEffort case deterministic 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
d589b3a95c test(gateway): clear agentCommand mock before strict bestEffort assert 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
03122e5933 fix(cron): preserve telegram announce target + delivery truth 2026-02-23 11:45:18 +05:30
Ayaan Zaidi
dcc52850c3 fix: persist resolved telegram delivery targets at runtime 2026-02-23 11:45:18 +05:30
Tak Hoffman
35fbf26d24 Gateway: suppress tools.catalog plugin conflict diagnostics 2026-02-23 00:05:57 -06:00
Tak Hoffman
9e1a13bf4c Gateway/UI: data-driven agents tools catalog with provenance (openclaw#24199) thanks @Takhoffman
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- gh pr checks 24199 --watch --fail-fast

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 23:55:59 -06:00
Peter Steinberger
1c753ea786 test: dedupe fixtures and test harness setup 2026-02-23 05:45:54 +00:00
Peter Steinberger
8af19ddc5b refactor: extract shared dedupe helpers for runtime paths 2026-02-23 05:43:43 +00:00
Peter Steinberger
9f508056d3 test: collapse remaining trigger command shards 2026-02-23 05:22:24 +00:00
Peter Steinberger
d90e9f561f test: merge overlapping trigger-handling suites 2026-02-23 05:19:23 +00:00
Peter Steinberger
af547ec52c test: consolidate trigger-handling suites 2026-02-23 05:15:35 +00:00
Evgeny Zislis
78f801e243 Validate Telegram delivery targets to reject invalid formats (#21930)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 02c9b1c3dd
Co-authored-by: kesor <7056+kesor@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-23 10:44:46 +05:30
Peter Steinberger
23598e0e3a test: prune redundant abort case and speed stream cap test 2026-02-23 05:06:34 +00:00
Tak Hoffman
77c3b142a9 Web UI: add full cron edit parity, all-jobs run history, and compact filters (openclaw#24155) thanks @Takhoffman
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 23:05:42 -06:00
Peter Steinberger
610863e733 test: speed up long-running async suites 2026-02-23 05:03:15 +00:00
Peter Steinberger
48f327c206 test: consolidate redundant suites and speed attachment tests 2026-02-23 04:55:43 +00:00
Peter Steinberger
86a8b65e9d test: consolidate redundant suites and speed up timers 2026-02-23 04:44:42 +00:00
Peter Steinberger
a6a2a9276e test: reduce exec timer test runtime 2026-02-23 04:25:00 +00:00
Peter Steinberger
384a161bbc test: consolidate media auto-detect coverage 2026-02-23 04:25:00 +00:00
Peter Steinberger
a53062ae3b refactor(test): deduplicate isolated agent cron test helpers 2026-02-23 04:20:41 +00:00
Peter Steinberger
382fe8009a refactor!: remove google-antigravity provider support 2026-02-23 05:20:14 +01:00
Peter Steinberger
558a0137bb chore(release): bump versions to 2026.2.23 2026-02-23 05:13:46 +01:00
Tak Hoffman
a54dc7fe80 Cron: suppress fallback main summary for delivery-target errors (openclaw#24074) thanks @Takhoffman
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 20:24:08 -06:00
Tak Hoffman
457835b104 Compaction: count only completed auto-compactions (#24056)
* Compaction: count only completed auto-compactions

* Compaction: count only non-retry completions

* Changelog: note completed-only compaction counting

* Agents/Compaction: guard optional compaction increment
2026-02-22 20:16:45 -06:00
Tak Hoffman
05691be511 Compaction: ignore tool result details in oversized checks (#24057)
* Compaction: ignore tool result details in oversized checks

* Tests/Compaction: type estimateTokens message callback
2026-02-22 20:13:59 -06:00
Tak Hoffman
5c9f9722af Agent runner: align compaction floor guidance (#24059) 2026-02-22 20:13:43 -06:00
Tak Hoffman
50c5f75904 Compaction: sanitize token split accounting (#24058)
* Compaction: sanitize token split accounting

* Tests/Compaction: type sanitize token estimate callback
2026-02-22 20:13:21 -06:00
Tak Hoffman
259d863353 Gateway: harden cron.runs jobId path handling (openclaw#24038) thanks @Takhoffman
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 19:35:26 -06:00
Peter Steinberger
45febecf2a fix(exec): keep implicit sandbox default and restore no-alert baseline 2026-02-23 02:17:43 +01:00
Tak Hoffman
f6c2e99f5d Cron: preserve due jobs after manual runs (#23994) 2026-02-22 19:02:05 -06:00
Peter Steinberger
bac26b4472 chore(release): bump version to 2026.2.22-1 2026-02-23 01:59:52 +01:00
Vignesh Natarajan
a10ec2607f Gateway/Chat UI: sanitize untrusted wrapper markup in final payloads 2026-02-22 16:53:54 -08:00
Peter Steinberger
b482da8c9a chore: update appcast for 2026.2.22 beta.1 2026-02-23 01:52:53 +01:00
Peter Steinberger
80f430c2be fix(daemon): extend restart health timeout and improve restart errors 2026-02-23 01:50:02 +01:00
Peter Steinberger
278331c49c fix(exec): restore sandbox as implicit host default 2026-02-23 01:48:24 +01:00
Tak Hoffman
211ab9e4f6 Cron: persist manual run marker before unlock (#23993)
* Cron: persist manual run marker before unlock

* Cron tests: relax wakeMode now microtask wait after run lock persist
2026-02-22 18:39:37 -06:00
SleuthCo.AI
9c87b53c8e security(cli): redact sensitive values in config get output (#23654)
* security(cli): redact sensitive values in config get output

`runConfigGet()` reads raw config values but never applies redaction
before printing. When a user runs `openclaw config get gateway.token`
the real credential is printed to the terminal, leaking it into shell
history, scrollback buffers, and screenshots.

Use the existing `redactConfigObject()` (from redact-snapshot.ts,
already used by the Web UI path) to scrub sensitive fields before
`getAtPath()` resolves the requested key.

Fixes #13683

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

* CLI/Config: add redaction regression test and changelog

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 19:37:33 -05:00
Vignesh Natarajan
f0542df9f0 Docker: precreate identity dir in docker setup 2026-02-22 16:33:53 -08:00
Peter Steinberger
70dd6a30e7 chore(synology-chat): allow npm publish for plugin package 2026-02-23 01:30:36 +01:00
Peter Steinberger
b19a6ee62d docs(changelog): move mistral to top and add synology chat 2026-02-23 01:25:22 +01:00
Peter Steinberger
cc8e6e9939 fix(synology-chat): align docs metadata and declare runtime deps 2026-02-23 01:24:51 +01:00
Peter Steinberger
0371646a61 test: fix msteams shared attachment fetch mock typing 2026-02-23 00:19:40 +00:00
Peter Steinberger
60c494c024 test: tighten mistral media and onboarding coverage 2026-02-23 00:19:05 +00:00
Phineas1500
8a8faf066e doctor: clean up legacy Linux gateway services (#21188)
* Doctor: clean up legacy Linux gateway services

* doctor: refactor legacy service cleanup flow

* doctor: fix legacy systemd cleanup map key typing

* doctor: add changelog entry for legacy Linux service cleanup

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 19:18:59 -05:00
Peter Steinberger
1c2c7843a8 docs: add synology channel docs and fix unreleased changelog 2026-02-23 01:16:05 +01:00
Phineas1500
320b62265d fix(models): synthesize antigravity Gemini 3.1 pro high/low models (#22899)
* Models: add antigravity Gemini 3.1 forward-compat

* models: propagate availability to Gemini 3.1 dot IDs

* test(models): format Gemini 3.1 forward-compat test

* test(models): type Gemini 3.1 forward-compat fixtures

* models: add changelog note for antigravity gemini 3.1 forward-compat

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 19:11:39 -05:00
Vignesh Natarajan
5c7c37a02a Agents: infer auth-profile unavailable failover reason 2026-02-22 16:10:32 -08:00
Phineas1500
331b728b8d fix(tui): add OSC 8 hyperlinks for wrapped URLs (#17814)
* feat(tui): add OSC 8 hyperlinks to make wrapped URLs clickable

Long URLs that exceed terminal width get broken across lines by pi-tui's
word wrapping, making them unclickable. Post-process rendered markdown
output to add OSC 8 terminal hyperlink sequences around URL fragments,
so each line fragment links to the full URL. Gracefully degrades on
terminals without OSC 8 support.

* tui: harden OSC8 URL extraction and prefix resolution

* tui: add changelog entry for OSC 8 markdown hyperlinks

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 19:09:07 -05:00
Vincent Koc
d92ba4f8aa feat: Provider/Mistral full support for Mistral on OpenClaw 🇫🇷 (#23845)
* Onboard: add Mistral auth choice and CLI flags

* Onboard/Auth: add Mistral provider config defaults

* Auth choice: wire Mistral API-key flow

* Onboard non-interactive: support --mistral-api-key

* Media understanding: add Mistral Voxtral audio provider

* Changelog: note Mistral onboarding and media support

* Docs: add Mistral provider and onboarding/media references

* Tests: cover Mistral media registry/defaults and auth mapping

* Memory: add Mistral embeddings provider support

* Onboarding: refresh Mistral model metadata

* Docs: document Mistral embeddings and endpoints

* Memory: persist Mistral embedding client state in managers

* Memory: add regressions for mistral provider wiring

* Gateway: add live tool probe retry helper

* Gateway: cover live tool probe retry helper

* Gateway: retry malformed live tool-read probe responses

* Memory: support plain-text batch error bodies

* Tests: add Mistral Voxtral live transcription smoke

* Docs: add Mistral live audio test command

* Revert: remove Mistral live voice test and docs entry

* Onboard: re-export Mistral default model ref from models

* Changelog: credit joeVenner for Mistral work

* fix: include Mistral in auto audio key fallback

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-02-23 00:03:56 +00:00
yinghaosang
a66b98a9da fix(plugins): hook systemPrompt gets collected then thrown away (#14583) (#14602)
* fix(plugins): apply before_agent_start hook systemPrompt to session (#14583)

* fix(plugins): apply legacy systemPrompt override and add changelog credit

---------

Co-authored-by: yinghaosang <yinghaosang@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 18:58:21 -05:00
Peter Steinberger
970062872f chore: remove deprecated npm allow-build-scripts config 2026-02-23 00:51:00 +01:00
Peter Steinberger
14c54e6501 fix(reasoning): persist off override for discord directives 2026-02-23 00:50:13 +01:00
Peter Steinberger
f79e3d5f03 fix(agents): remove synthetic done fallback reply 2026-02-23 00:50:00 +01:00
Vignesh Natarajan
1000ff04ea fix(memory): hard-cap embedding inputs before batch 2026-02-22 15:40:18 -08:00
Aether AI
d306fc8ef1 fix(security): OC-07 redact session history credentials and enforce webhook secret (#16928)
* Security: refresh sessions history redaction patch

* tests: align sessions_history redaction-only truncation expectation

* Changelog: credit sessions history security hardening

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 18:29:40 -05:00
Tak Hoffman
3efe63d1ad Cron: respect aborts in main wake-now retries (#23967)
* Cron: respect aborts in main wake-now retries

* Changelog: add main-session cron abort retry fix note

* Cron tests: format post-rebase conflict resolution
2026-02-22 17:19:27 -06:00
Tak Hoffman
9bc265f379 Cron: clean run-log write queue entries (#23968)
* Cron: clean run-log write queue entries

* Changelog: add cron run-log write-queue cleanup note
2026-02-22 17:16:42 -06:00
Johann Zahlmann
22c9018303 WhatsApp: enforce allowFrom for explicit outbound sends (#20921)
* whatsapp: enforce allowFrom in explicit outbound mode

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 18:13:23 -05:00
Vignesh Natarajan
d7747148d0 fix(memory): reindex when sources change 2026-02-22 15:12:07 -08:00
Robin Waslander
44727dc3a1 security(web_fetch): strip hidden content to prevent indirect prompt injection (#21074)
* security(web_fetch): strip hidden content to prevent indirect prompt injection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* security(web_fetch): address review feedback and credit author

* chore(changelog): credit reporter for web_fetch security fix

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 18:10:26 -05:00
Tak Hoffman
73e5bb7635 Cron: apply timeout to startup catch-up runs (#23966)
* Cron: apply timeout to startup catch-up runs

* Changelog: add cron startup timeout catch-up note
2026-02-22 17:04:30 -06:00
Lewis
26644c4b89 fix(msteams): add SSRF protection to attachment downloads via redirect and DNS validation (#23598)
* fix(msteams): add SSRF protection to attachment downloads via redirect and DNS validation

The attachment download flow in fetchWithAuthFallback() followed
redirects automatically on the initial fetch without any allowlist
or IP validation. This allowed DNS rebinding attacks where an
allowlisted domain (e.g. evil.trafficmanager.net) could redirect
or resolve to a private IP like 169.254.169.254, bypassing the
hostname allowlist entirely (issue #11811).

This commit adds three layers of SSRF protection:

1. safeFetch() in shared.ts: a redirect-safe fetch wrapper that uses
   redirect: "manual" and validates every redirect hop against the
   hostname allowlist AND DNS-resolved IP before following it.

2. isPrivateOrReservedIP() + resolveAndValidateIP() in shared.ts:
   rejects RFC 1918, loopback, link-local, and IPv6 private ranges
   for both initial URLs and redirect targets.

3. graph.ts SharePoint redirect handling now also uses redirect:
   "manual" and validates resolved IPs, not just hostnames.

The initial fetch in fetchWithAuthFallback now goes through safeFetch
instead of a bare fetch(), ensuring redirects are never followed
without validation.

Includes 38 new tests covering IP validation, DNS resolution checks,
redirect following, DNS rebinding attacks, redirect loops, and
protocol downgrade blocking.

* fix: address review feedback on SSRF protection

- Replace hand-rolled isPrivateOrReservedIP with SDK's isPrivateIpAddress
  which handles IPv4-mapped IPv6, expanded notation, NAT64, 6to4, Teredo,
  octal IPv4, and fails closed on parse errors
- Add redirect: "manual" to auth retry redirect fetch in download.ts to
  prevent chained redirect attacks bypassing SSRF checks
- Add redirect: "manual" to SharePoint redirect fetch in graph.ts to
  prevent the same chained redirect bypass
- Update test expectations for SDK's fail-closed behavior on malformed IPs
- Add expanded IPv6 loopback (0:0:0:0:0:0:0:1) test case

* fix: type fetchMock as typeof fetch to fix TS tuple index error

* msteams: harden attachment auth and graph redirect fetch flow

* changelog(msteams): credit redirect-safeFetch hardening contributors

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 18:00:54 -05:00
Vignesh Natarajan
a58b40e153 chore(test): stabilize mcporter assertions on Windows 2026-02-22 14:47:50 -08:00
Vignesh Natarajan
82d34b4b06 fix(memory): harden qmd collection recovery 2026-02-22 14:40:04 -08:00
Peter Steinberger
5858de6078 docs: reorder 2026.2.22 changelog by user impact 2026-02-22 23:37:44 +01:00
Peter Steinberger
84e5ab598a fix: make windows CI path handling deterministic 2026-02-22 22:34:49 +00:00
Peter Steinberger
3b0e62d5bf fix(doctor): warn that approvals.exec.enabled only disables forwarding
Co-authored-by: nomadonwheels196 <nomadonwheels196@users.noreply.github.com>
2026-02-22 23:33:15 +01:00
Peter Steinberger
a30f9c8673 fix(sandbox): fallback docker user to workspace owner uid/gid
Co-authored-by: LucasAIBuilder <LucasAIBuilder@users.noreply.github.com>
2026-02-22 23:33:15 +01:00
Peter Steinberger
394a1af70f fix(exec): apply per-agent exec defaults for opaque session keys
Co-authored-by: brin-tapcart <brin-tapcart@users.noreply.github.com>
2026-02-22 23:33:14 +01:00
Peter Steinberger
427b4360b9 build: update deps and stabilize tests 2026-02-22 23:32:38 +01:00
Peter Steinberger
a5917e4ad8 test(exec): resolve rebase artifact in bash-tools test 2026-02-22 22:25:47 +00:00
Peter Steinberger
1d8968c8a8 fix(voice-call): harden media stream pre-start websocket handling 2026-02-22 23:25:32 +01:00
Vignesh Natarajan
1ad9f9af5a fix(memory): resolve qmd Windows shim commands 2026-02-22 14:24:49 -08:00
Peter Steinberger
cd919ebd2d refactor(exec): unify wrapper resolution and split approvals tests 2026-02-22 23:20:09 +01:00
mudrii
3645420a33 perf: skip cache-busting for bundled hooks, use mtime for workspace hooks (openclaw#16960) thanks @mudrii
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: mudrii <220262+mudrii@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 16:14:51 -06:00
Peter Steinberger
84303f6a78 test: make exec timeout coverage deterministic 2026-02-22 22:14:01 +00:00
Peter Steinberger
13db0b88f5 refactor(gateway): share safe avatar file open checks 2026-02-22 23:12:45 +01:00
Peter Steinberger
7b229decdd test(perf): dedupe fixtures and reduce flaky waits 2026-02-22 22:06:01 +00:00
Peter Steinberger
b534dfa3e0 fix(slack,web): harden thread hints and monitor tuning 2026-02-22 22:06:01 +00:00
Peter Steinberger
57b75678d4 test(security): consolidate runtime guardrail scans 2026-02-22 22:06:01 +00:00
Peter Steinberger
fe58839ed1 docs(changelog): thank ghsa reporter for exec fix 2026-02-22 23:04:29 +01:00
Peter Steinberger
498138e77e docs(changelog): record avatar security hardening 2026-02-22 23:04:23 +01:00
Peter Steinberger
9a6a4131ba docs(changelog): note shell-wrapper line-continuation exec hardening 2026-02-22 23:03:53 +01:00
Peter Steinberger
c677be9d5f fix(exec): skip default timeout for background sessions 2026-02-22 23:03:44 +01:00
Peter Steinberger
4b0fddc075 fix(test): prevent env leak causing models.json CI flake 2026-02-22 22:00:44 +00:00
Peter Steinberger
862975507a refactor(exec): split command resolution and trusted-dir normalization 2026-02-22 23:00:33 +01:00
Peter Steinberger
70cac824b1 perf(test): optimize parallel vitest worker budget 2026-02-22 21:59:22 +00:00
Peter Steinberger
24c954d972 fix(security): harden allow-always wrapper persistence 2026-02-22 22:55:33 +01:00
Peter Steinberger
4adfe80027 fix(extensions): preserve mediaLocalRoots in telegram/discord sendMedia 2026-02-22 22:53:57 +01:00
Peter Steinberger
1e582dcc6f fix: harden windows path handling in CI tests 2026-02-22 21:52:10 +00:00
Tak Hoffman
556af3f08b fix(cron): cancel timed-out runs before side effects (openclaw#22411) thanks @Takhoffman
Verified:
- pnpm check
- pnpm vitest run src/memory/qmd-manager.test.ts src/cron/service.issue-regressions.test.ts src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts --maxWorkers=1

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-22 15:45:27 -06:00
Peter Steinberger
64b273a71c fix(exec): harden safe-bin trust and add explicit trusted dirs 2026-02-22 22:43:18 +01:00
Vignesh Natarajan
08fb38f729 Fix: resolve pnpm check type regressions 2026-02-22 13:40:51 -08:00
Peter Steinberger
6970c2c2db fix(gateway): harden control-ui avatar reads 2026-02-22 22:40:22 +01:00
Peter Steinberger
e16f93af0c fix: stabilize ci test typings and mocks 2026-02-22 21:38:47 +00:00
Peter Steinberger
3f0b9dbb36 fix(security): block shell-wrapper line-continuation allowlist bypass 2026-02-22 22:36:29 +01:00
Peter Steinberger
7c109f5737 fix: resolve ci type errors and reconnect test flake 2026-02-22 21:35:20 +00:00
Vignesh Natarajan
d75b594e07 Agents/Replies: scope done fallback to direct sessions 2026-02-22 13:30:30 -08:00
Peter Steinberger
e4d67137db fix(node): default mac headless system.run to local host
Co-authored-by: aethnova <262512133+aethnova@users.noreply.github.com>
2026-02-22 22:24:28 +01:00
Peter Steinberger
d24f5c1e3a fix(gateway): fail fast exec approvals when no approvers are reachable
Co-authored-by: fanxian831-netizen <262880470+fanxian831-netizen@users.noreply.github.com>
2026-02-22 22:24:27 +01:00
Peter Steinberger
73fab7e445 fix(agents): map container workdir paths in workspace guard
Co-authored-by: Explorer1092 <32663226+Explorer1092@users.noreply.github.com>
2026-02-22 22:24:27 +01:00
Peter Steinberger
7bbd597383 fix(media): enforce agent media roots in plugin send actions
Co-authored-by: Oliver Drobnik <333270+odrobnik@users.noreply.github.com>
Co-authored-by: thisischappy <257418353+thisischappy@users.noreply.github.com>
2026-02-22 22:24:27 +01:00
Peter Steinberger
33a43a151d refactor(security): split elevated allowFrom matcher internals 2026-02-22 22:20:04 +01:00
Peter Steinberger
2081b3a3c4 refactor(channels): dedupe hook and monitor execution paths 2026-02-22 21:19:09 +00:00
Peter Steinberger
06b0a60bef refactor(daemon): share runtime and service probe helpers 2026-02-22 21:19:09 +00:00
Peter Steinberger
e029f78447 refactor(config): dedupe install and typing schema definitions 2026-02-22 21:19:09 +00:00
Peter Steinberger
4bf67ab698 refactor(commands): centralize shared command formatting helpers 2026-02-22 21:19:09 +00:00
Peter Steinberger
06bdd53658 refactor(agents): dedupe workspace and session tool flows 2026-02-22 21:19:09 +00:00
Peter Steinberger
2f8c68ae4d refactor(test): dedupe run-loop signal harness setup 2026-02-22 21:19:09 +00:00
Peter Steinberger
52ee1f697e test(memory): cover shared batch output and error helpers 2026-02-22 21:19:09 +00:00
Peter Steinberger
ad51372f78 refactor(memory): share batch provider scaffolding 2026-02-22 21:19:09 +00:00
Tak Hoffman
f8171ffcdc Config UI: tag filters and complete schema help/labels coverage (#23796)
* Config UI: add tag filters and complete schema help/labels

* Config UI: finalize tags/help polish and unblock test suite

* Protocol: regenerate Swift gateway models
2026-02-22 15:17:07 -06:00
Peter Steinberger
c539782c09 test(gateway-lock): stabilize port-probe liveness coverage 2026-02-22 22:11:53 +01:00
Peter Steinberger
ffb12397a8 fix(cron): direct-deliver thread and topic announce targets
Co-authored-by: Andrei Aratmonov <247877121+AndrewArto@users.noreply.github.com>
2026-02-22 22:11:52 +01:00
Peter Steinberger
320cf8eb3e fix(subagents): restore configurable announce timeout
Co-authored-by: Valadon <20071960+Valadon@users.noreply.github.com>
2026-02-22 22:11:52 +01:00
Peter Steinberger
3820ad77ba fix(cron): pass agentDir into embedded follow-up runs
Co-authored-by: seilk <88271769+seilk@users.noreply.github.com>
2026-02-22 22:11:52 +01:00
Peter Steinberger
34fef3ae60 fix(delivery): quarantine permanent recovery failures
Co-authored-by: Aldo <17973757+aldoeliacim@users.noreply.github.com>
2026-02-22 22:11:51 +01:00
Peter Steinberger
e6383a2c13 fix(gateway): probe port liveness for stale lock recovery
Co-authored-by: Operative-001 <261882263+Operative-001@users.noreply.github.com>
2026-02-22 22:11:51 +01:00
Peter Steinberger
9165bd7f37 fix(gateway): auto-approve loopback scope upgrades
Co-authored-by: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com>
2026-02-22 22:11:50 +01:00
Peter Steinberger
6817c0ec7b fix(security): tighten elevated allowFrom sender matching 2026-02-22 22:00:08 +01:00
Peter Steinberger
3a088c9f4f docs: prune completed experiment plan notes 2026-02-22 21:56:01 +01:00
Peter Steinberger
382785c6ce refactor(webchat): extract shared chat state helpers 2026-02-22 21:37:19 +01:00
Peter Steinberger
d574056761 fix(control-ui): send stable websocket instance IDs (#23616)
Co-authored-by: zq58855371-ui <248869919+zq58855371-ui@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
dc6afeb4f8 perf(webchat): skip unnecessary full history reloads on final events (#20588)
Co-authored-by: amzzzzzzz <154392693+amzzzzzzz@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
f2e9986813 fix(webchat): append out-of-band final payloads in active chat (#11139)
Co-authored-by: AkshayNavle <110360+AkshayNavle@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
8264d4521b fix(webchat): render final assistant payloads without history wait (#14928)
Co-authored-by: BradGroux <3053586+BradGroux@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
02dc0c8752 fix(control-ui): stop websocket client on lifecycle teardown (#23422)
Co-authored-by: floatinggball-design <262259579+floatinggball-design@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
19046e0cfc fix(webchat): preserve session labels across /new resets (#23755)
Co-authored-by: ThunderStormer <16649514+ThunderStormer@users.noreply.github.com>
2026-02-22 21:37:19 +01:00
Peter Steinberger
8a83ca54a1 fix(webchat): preserve session channel routing on internal turns (#23258)
Co-authored-by: binary64 <1680627+binary64@users.noreply.github.com>
2026-02-22 21:37:18 +01:00
Peter Steinberger
b0252ab90c docs: fix canonical session doc path hint 2026-02-22 21:35:14 +01:00
Peter Steinberger
13541864e5 refactor: extract telegram lane delivery and e2e harness 2026-02-22 21:33:20 +01:00
Peter Steinberger
acfbe158c6 docs: point pi extension paths to real source files 2026-02-22 21:32:28 +01:00
Peter Steinberger
820d765553 docs: update outbound refactor test path 2026-02-22 21:28:08 +01:00
Peter Steinberger
6ed08ddc24 docs: fix stale test file paths in experiment plans 2026-02-22 21:24:48 +01:00
Sid
3bfe990c33 fix(skill-creator): exclude .git and VCS internals from .skill archives (#23180)
The packager included .git directory contents in .skill archives,
causing unnecessary bloat, metadata leakage, and poor artifact hygiene.

Hard-exclude .git, .svn, .hg, __pycache__, and node_modules from
packaged archives. These paths are never useful in distributable skills.

Fixes #23149

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-22 15:24:11 -05:00
Peter Steinberger
3f64d4ad7b refactor(config): compile toolsBySender policy and migrate legacy keys 2026-02-22 21:22:20 +01:00
Peter Steinberger
c73837d269 docs: replace stale pi test file list with maintained patterns 2026-02-22 21:21:08 +01:00
Peter Steinberger
7eae1933fb refactor(test): extract shared fixture helpers in gateway and outbound tests 2026-02-22 20:18:20 +00:00
Peter Steinberger
5d90e31807 refactor(cron): share timed job-execution helper 2026-02-22 20:18:20 +00:00
Peter Steinberger
dff9ead59a docs: refresh gateway test references in testing guide 2026-02-22 21:16:53 +01:00
Peter Steinberger
30e8f41cfc docs: fix stale release checklist source paths 2026-02-22 21:15:09 +01:00
Peter Steinberger
06b4baf67f docs: remove internal hook import paths from examples 2026-02-22 21:12:49 +01:00
Peter Steinberger
5dba7501c9 docs: update stale tsgo reference in pty plan 2026-02-22 21:10:14 +01:00
Peter Steinberger
9c480d4dea docs: replace removed pi test script with current commands 2026-02-22 21:07:34 +01:00
Peter Steinberger
8af6d1a186 refactor(test): dedupe repeated fixture setup helpers 2026-02-22 20:04:51 +00:00
Peter Steinberger
6ef4eda1f0 refactor(memory): share post-json helper across remote fetchers 2026-02-22 20:04:51 +00:00
Peter Steinberger
2dcb244985 refactor(test): dedupe gateway and web scaffolding 2026-02-22 20:04:51 +00:00
Peter Steinberger
5e8b1f5ac8 refactor(test): centralize trigger and cron test helpers 2026-02-22 20:04:51 +00:00
Peter Steinberger
3c75bc0e41 refactor(test): dedupe agent and discord test fixtures 2026-02-22 20:04:51 +00:00
Peter Steinberger
5547a2275c fix(security): harden toolsBySender sender-key matching 2026-02-22 21:04:37 +01:00
Peter Steinberger
3461dda880 docs: fix voicecall expose disable example 2026-02-22 20:58:28 +01:00
Peter Steinberger
0d4c806406 docs: fix devices approve command in exe.dev guide 2026-02-22 20:52:46 +01:00
Peter Steinberger
e0d4194869 docs: add missing summary/read_when metadata 2026-02-22 20:45:09 +01:00
Vincent Koc
5e73f33448 fix(slack): keep thread session fork/history context after first turn (#23843)
* Slack thread sessions: keep forking and history context after first turn

* Update CHANGELOG.md
2026-02-22 14:39:00 -05:00
Peter Steinberger
02772b029d fix(security): require sender-only matching for elevated allowFrom
Co-authored-by: coygeek <coygeek@users.noreply.github.com>
2026-02-22 20:37:22 +01:00
Peter Steinberger
51b0772e14 fix(exec-approvals): harden forwarding target and resolve delivery paths
Co-authored-by: bubmiller <bubmiller@users.noreply.github.com>
2026-02-22 20:37:22 +01:00
Peter Steinberger
6f895eb831 fix(sandbox): honor explicit bind mounts over workspace defaults
Co-authored-by: tasaankaeris <tasaankaeris@users.noreply.github.com>
2026-02-22 20:37:22 +01:00
Peter Steinberger
eefbf3dc5a fix(sandbox): normalize /workspace media paths to host sandbox root
Co-authored-by: echo931 <echo931@users.noreply.github.com>
2026-02-22 20:37:21 +01:00
Peter Steinberger
0932adf361 fix(config): fail closed allowlist-only group policy
Co-authored-by: etereo <etereo@users.noreply.github.com>
2026-02-22 20:37:21 +01:00
Peter Steinberger
371a7da9c8 docs: add missing summaries and read_when hints 2026-02-22 20:37:02 +01:00
Vincent Koc
71c2c59c6c fix(slack): enforce replyToMode for auto-thread_ts and inline reply tags (#23839)
* Slack: respect replyToMode for auto-thread_ts and inline reply tags

* Update CHANGELOG.md
2026-02-22 14:36:46 -05:00
Vincent Koc
9f7c1686b4 fix(slack extension): preserve thread IDs for read + outbound delivery (#23836)
* Slack Extension: preserve thread IDs in reads and outbound sends

* Slack extension: fix threadTs typing and action test context

* Update CHANGELOG.md
2026-02-22 14:34:32 -05:00
Peter Steinberger
078e1a7fc9 fix(ui): remove unused Lit import in overview view 2026-02-22 20:32:52 +01:00
Peter Steinberger
f5814cc002 docs: add extension channels to Channels nav 2026-02-22 20:28:05 +01:00
Peter Steinberger
00bbecede7 test(gateway): add telegram-session chat.send final-event e2e coverage 2026-02-22 20:26:50 +01:00
Peter Steinberger
290f375aa1 docs: fix Together provider env path 2026-02-22 20:23:40 +01:00
Peter Steinberger
bbdfba5694 fix: harden connect auth flow and exec policy diagnostics 2026-02-22 20:22:00 +01:00
Peter Steinberger
7e83e7b3a7 fix(cron): narrow manual run execution state 2026-02-22 20:19:23 +01:00
Peter Steinberger
9cf445e37c fix(cron): restore interval cadence after restart 2026-02-22 20:19:23 +01:00
Peter Steinberger
aa4c250eb8 fix(cron): split run and delivery status tracking 2026-02-22 20:19:23 +01:00
Peter Steinberger
c3bb723673 fix(cron): enforce timeout for manual cron runs 2026-02-22 20:19:23 +01:00
Peter Steinberger
8bf3c37c6c fix(cron): keep watchdog timer armed during ticks 2026-02-22 20:19:23 +01:00
Peter Steinberger
5db1ee4ec6 fix(cron): keep manual runs non-blocking 2026-02-22 20:19:22 +01:00
Peter Steinberger
91f75a2b33 fix(cron): force fresh isolated session IDs 2026-02-22 20:19:22 +01:00
Peter Steinberger
6fef318fda docs: replace legacy chat examples in Venice provider guide 2026-02-22 20:15:07 +01:00
Peter Steinberger
72446f419f docs: align CLI docs and help surface 2026-02-22 20:05:01 +01:00
Val Alexander
42b3c52350 fix(ui): ensure nonce is always a string in gateway connect 2026-02-22 13:04:28 -06:00
Val Alexander
6298698008 revert(ui): remove UI portions of mixed commits from main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-22 13:01:22 -06:00
Val Alexander
26ab93f0eb revert(ui): remove recent UI dashboard/theme commits from main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-22 13:01:22 -06:00
Peter Steinberger
8eb71cec26 test(agents): add malformed MEDIA prose integration coverage
Co-authored-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-02-22 19:59:03 +01:00
Peter Steinberger
c3d11d56c3 fix(agents): validate tool-result MEDIA directives with shared parser
Co-authored-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-02-22 19:52:29 +01:00
Peter Steinberger
0c1f491a02 fix(gateway): clarify pairing and node auth guidance 2026-02-22 19:50:29 +01:00
Peter Steinberger
53ed7a0f5c test: dedupe repeated test fixtures and assertions 2026-02-22 18:37:25 +00:00
Peter Steinberger
0e4f3ccbdf refactor: dedupe media and request-body test scaffolding 2026-02-22 18:37:25 +00:00
Peter Steinberger
4a88c579ba refactor: dedupe shared config type definitions 2026-02-22 18:37:25 +00:00
Peter Steinberger
12635de1c7 test: cover shared installer flow helpers 2026-02-22 18:37:25 +00:00
Peter Steinberger
07888bee34 refactor: share install flows across hooks and plugins 2026-02-22 18:37:25 +00:00
Peter Steinberger
176973b882 test(gateway): align auto-enable channel assertion 2026-02-22 18:37:18 +00:00
青雲
3dfee78d72 fix: sanitize tool call IDs in agent loop for Mistral strict9 format (#23595) (#23698)
* fix: sanitize tool call IDs in agent loop for Mistral strict9 format (#23595)

Mistral requires tool call IDs to be exactly 9 alphanumeric characters
([a-zA-Z0-9]{9}). The existing sanitizeToolCallIdsForCloudCodeAssist
mechanism only ran on historical messages at attempt start via
sanitizeSessionHistory, but the pi-agent-core agent loop's internal
tool call → tool result cycles bypassed that path entirely.

Changes:
- Wrap streamFn (like dropThinkingBlocks) so every outbound request
  sees sanitized tool call IDs when the transcript policy requires it
- Replace call_${Date.now()} in pendingToolCalls with a 9-char hex ID
  generated from crypto.randomBytes
- Add Mistral tool call ID error pattern to ERROR_PATTERNS.format so
  the error is correctly classified for retry/rotation

* Changelog: document Mistral strict9 tool-call ID fix

---------

Co-authored-by: echoVic <AkiraVic@outlook.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 13:37:12 -05:00
Drake Thomsen
042947b944 fix: add mistral to MemorySearchSchema provider/fallback unions (#14934)
* fix: add mistral to MemorySearchSchema provider/fallback unions

The Mistral embedding provider was added to the runtime code but the
Zod config schema was not updated, causing config validation to reject
`provider: "mistral"` and `fallback: "mistral"` as invalid input.

* Changelog: add unreleased note for Mistral memory schema fix

---------

Co-authored-by: Drake (Moltbot Dev) <drake@clawd.bot>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 13:36:53 -05:00
Peter Steinberger
95d7b0bbe1 fix(replies): normalize media path variants for dedupe
Co-authored-by: Ho Lim <subhoya@gmail.com>
2026-02-22 19:34:30 +01:00
Peter Steinberger
0342bed289 fix(replies): keep finals for cross-target messaging sends
Co-authored-by: Ion Mudreac <mudreac@gmail.com>
2026-02-22 19:34:30 +01:00
Peter Steinberger
40680432b4 fix(config): allowlist auto-enabled built-in channels when restricted
Co-authored-by: 4rev <4rev@users.noreply.github.com>
2026-02-22 19:31:18 +01:00
Peter Steinberger
772cf7df33 test: load chrome extension background utils across module modes 2026-02-22 18:29:20 +00:00
Vincent Koc
89a1e99815 fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack

Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':

1. Typing indicator created threads via statusThreadTs fallback (#16868)
   - resolveSlackThreadTargets fell back to messageTs for statusThreadTs
   - 'is typing...' was posted as thread reply, creating a thread
   - Fix: remove messageTs fallback, let statusThreadTs be undefined

2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
   - Slack dock had allowExplicitReplyTagsWhenOff: true
   - Reply tags from system prompt always threaded regardless of config
   - Fix: set allowExplicitReplyTagsWhenOff to false for Slack

3. Contradictory replyToMode defaults in codebase (#20827)
   - monitor/provider.ts defaulted to 'all'
   - accounts.ts defaulted to 'off' (matching docs)
   - Fix: align provider.ts default to 'off' per documentation

Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827

* fix(slack): respect replyToMode in DMs even with typing indicator thread

When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.

Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.

Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).

This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity

Fixes #16868

* refactor(slack): eliminate redundant resolveSlackThreadContext call

- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach

* docs(slack): add JSDoc to resolveSlackThreadTargets

Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.

* docs(changelog): record Slack replyToMode off threading fixes

---------

Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
2026-02-22 13:27:50 -05:00
Vincent Koc
cd7b2814af fix(slack): preserve string thread context in queue + DM route (#23804)
* fix(slack): preserve thread_ts in queue drain and deliveryContext

Two related fixes for Slack thread reply routing:

1. Queue drain drops string thread_ts (#11195)
   - `typeof threadId === "number"` in drain.ts only matches Telegram numeric
     topic IDs. Slack thread_ts is a string like "1770474140.187459" which
     fails the check, causing threadKey to become empty.
   - Changed to `threadId != null && threadId !== ""` to accept both number
     and string thread IDs.
   - Applies to all 3 occurrences in drain.ts: cross-channel detection,
     thread key building, and collected originatingThreadId extraction.

2. DM deliveryContext missing thread_ts (#10837)
   - updateLastRoute calls for Slack DMs in both prepare.ts and dispatch.ts
     built deliveryContext without threadId, so the session's delivery context
     never included thread_ts for DM threads.
   - Added threadId from threadContext.messageThreadId / ctxPayload.MessageThreadId
     to both updateLastRoute call sites.

Tests: 3 new cases in queue.collect-routing.test.ts
- Collects messages with matching string thread_ts (same Slack thread)
- Separates messages with different string thread_ts (different threads)
- Treats empty string threadId same as absent

Closes #10837, closes #11195

* fix(slack): preserve string thread context in queue + DM route updates

---------

Co-authored-by: RobClawd <clawd@RobClawds-Mac-mini.local>
2026-02-22 13:26:31 -05:00
Peter Steinberger
b13bba9c35 fix(gateway): skip operator pairing on valid shared auth 2026-02-22 19:25:50 +01:00
Peter Steinberger
9da5f9819b fix(plugins): ignore archived extension dirs during discovery
Co-authored-by: chenzhuoms <chenzhuoms@users.noreply.github.com>
2026-02-22 19:23:34 +01:00
Peter Steinberger
8839162b97 fix(config): persist built-in channel enable state in channels
Co-authored-by: HirokiKobayashi-R <HirokiKobayashi-R@users.noreply.github.com>
2026-02-22 19:23:34 +01:00
Peter Steinberger
1bd79add8f fix(plugins): sanitize workspace deps before plugin install
Co-authored-by: guanyu-zhang <guanyu-zhang@users.noreply.github.com>
2026-02-22 19:23:34 +01:00
Peter Steinberger
e55ab6fd91 test(ci): harden background abort timing on windows 2026-02-22 18:23:04 +00:00
Peter Steinberger
1bc5ba6e29 fix(feishu): prefer video file_key for inbound media 2026-02-22 19:21:42 +01:00
Peter Steinberger
0efe2cab7d fix(telegram): set provider on native command context
Co-authored-by: Serhii Panchyshyn <panchyshyn.serhii@gmail.com>
2026-02-22 19:21:26 +01:00
Vincent Koc
fbdae49988 Changelog: fix unreleased thanks attribution placement 2026-02-22 13:18:23 -05:00
Vincent Koc
35a7f6e7f6 Dev tooling: prevent CLAUDE symlink newline regressions 2026-02-22 13:18:04 -05:00
Peter Steinberger
95e85e627e fix(feishu): restore group command fallback and plugin deps 2026-02-22 19:13:19 +01:00
Peter Steinberger
8801130c5d fix(ci): annotate shared skill-install test mocks 2026-02-22 18:10:56 +00:00
Peter Steinberger
2858901441 test(flaky): harden slow vmFork unit suites
Co-authored-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-02-22 19:08:59 +01:00
Peter Steinberger
9ea5228f42 fix(browser): recover stale remote target ids
Co-authored-by: Ilya Strelov <10761735+strelov1@users.noreply.github.com>
2026-02-22 19:08:38 +01:00
Peter Steinberger
1fe2043742 fix(browser): harden extension relay worker recovery
Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com>
2026-02-22 19:08:38 +01:00
Peter Steinberger
40494d67f2 fix(browser): harden extension relay reconnect race
Co-authored-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-02-22 19:08:38 +01:00
Peter Steinberger
b79c89fc90 fix: stabilize CI type and test harness coverage 2026-02-22 18:06:34 +00:00
Luis Conde
af9881b9c5 fix(slack): resolve user IDs to DM channels before files.uploadV2 (#23773)
When a bare Slack user ID (U-prefix) is passed as the send target
without an explicit `user:` prefix, `parseSlackTarget` classifies it as
kind="channel".  `resolveChannelId` then passes it through to callers
without calling `conversations.open`.

This works for `chat.postMessage` (which tolerates user IDs), but
`files.uploadV2` delegates to `completeUploadExternal` which validates
`channel_id` against `^[CGDZ][A-Z0-9]{8,}$` — rejecting U-prefixed
IDs with `invalid_arguments`.

Fix: detect U-prefixed IDs in `resolveChannelId` regardless of the
parsed `kind`, and always resolve them via `conversations.open` to
obtain the DM channel ID (D-prefix).

Includes test coverage for bare, prefixed, and mention-style user ID
targets with file uploads, plus a channel-target negative case.
2026-02-22 13:04:53 -05:00
Peter Steinberger
568973e5ac perf(test): trim embedded/bash runtime fixture overhead 2026-02-22 17:56:05 +00:00
Peter Steinberger
08431da5d5 refactor(gateway): unify credential precedence across entrypoints 2026-02-22 18:55:44 +01:00
Peter Steinberger
98427453ba fix(network): normalize SSRF IP parsing and monitor typing 2026-02-22 18:55:34 +01:00
Peter Steinberger
4ed87a6672 fix(feishu): enforce id-only allowlist matching 2026-02-22 18:55:06 +01:00
Peter Steinberger
3286791316 refactor(agents): dedupe config and truncation guards 2026-02-22 17:54:51 +00:00
Peter Steinberger
409a02691f refactor(discord): dedupe directory and media send paths 2026-02-22 17:54:51 +00:00
Peter Steinberger
b3c78e5e05 refactor(outbound): reuse signal uuid detection and payload types 2026-02-22 17:54:51 +00:00
Peter Steinberger
dacb3d1aa2 refactor(queue): share drain helpers across announce and reply 2026-02-22 17:54:51 +00:00
Peter Steinberger
78220db2be refactor(browser): dedupe control-server test harness 2026-02-22 17:54:51 +00:00
Peter Steinberger
79ec29b150 test: consolidate embedded prompt error scenarios 2026-02-22 17:53:33 +00:00
Peter Steinberger
239f72c582 perf(test): consolidate archive safety cases and cache session manager 2026-02-22 17:53:33 +00:00
Peter Steinberger
b17f677439 test: merge no-op notifyOnExit scenario coverage 2026-02-22 17:53:33 +00:00
Peter Steinberger
5b078c8305 test: consolidate sudo fallback edge-case scenarios 2026-02-22 17:53:12 +00:00
Peter Steinberger
03285465ff perf(test): lazy-load weak-random fallback scanner 2026-02-22 17:52:12 +00:00
Peter Steinberger
90a8ddc3c6 perf(test): replace temp-path guard AST parse with fast scanner 2026-02-22 17:52:12 +00:00
Peter Steinberger
2ed94a08c0 test: merge duplicate bash background session-name coverage 2026-02-22 17:52:12 +00:00
Peter Steinberger
60f3a2a244 perf(test): shorten bash tool timing fixtures 2026-02-22 17:52:12 +00:00
Peter Steinberger
61d0c55a80 perf(test): share workspace fixture in skills download safety suite 2026-02-22 17:52:12 +00:00
Peter Steinberger
1437f371fc test: trim duplicate embedded runner setup cases 2026-02-22 17:52:12 +00:00
Peter Steinberger
924455edb8 perf(test): reuse tar.bz2 workspace in download safety tests 2026-02-22 17:52:12 +00:00
Peter Steinberger
a28464ec59 test: combine duplicate process log tail-window coverage 2026-02-22 17:52:12 +00:00
Peter Steinberger
64ecd3e81c test: merge duplicate targetDir escape cases 2026-02-22 17:51:38 +00:00
Peter Steinberger
dd8c0b694d perf(test): speed async memory sync close coverage 2026-02-22 17:51:38 +00:00
Peter Steinberger
4493f7325d perf(test): run nodes program tests on focused nodes-cli harness 2026-02-22 17:51:38 +00:00
Peter Steinberger
0e38505d3d test: collapse duplicate sandbox skill mirroring cases 2026-02-22 17:51:38 +00:00
Peter Steinberger
c964d21d74 perf(test): prebuild download archives and cache apply module 2026-02-22 17:51:38 +00:00
Peter Steinberger
2962e5a383 perf(test): tighten temp-path dynamic prefilter 2026-02-22 17:51:38 +00:00
Peter Steinberger
d1836df714 test: trim duplicate plain nodes list smoke 2026-02-22 17:51:38 +00:00
Peter Steinberger
3e819f0af5 test: drop duplicate nodes media parser coverage 2026-02-22 17:51:38 +00:00
Peter Steinberger
cc5cd51b13 docs(changelog): note installer gum auto-path smoke coverage 2026-02-22 18:48:36 +01:00
Jonathan Works
8c089bbe32 fix(hooks): suppress main session events for silent/delivered hook turns (#20678)
* fix(hooks): suppress main session events for silent/delivered hook turns

When a hook agent turn returns NO_REPLY (SILENT_REPLY_TOKEN), mark the
result as delivered so the hooks handler skips enqueueSystemEvent and
requestHeartbeatNow. Without this, every Gmail notification classified
as NO_REPLY still injects a system event into the main agent session,
causing context window growth proportional to email volume.

Two-part fix:
- cron/isolated-agent/run.ts: set delivered:true when synthesizedText
  matches SILENT_REPLY_TOKEN so callers know no notification is needed
- gateway/server/hooks.ts: guard enqueueSystemEvent + requestHeartbeatNow
  with !result.delivered (addresses duplicate delivery, refs #20196)

Refs: https://github.com/openclaw/openclaw/issues/20196

* Changelog: document hook silent-delivery suppression fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:47:42 -05:00
Peter Steinberger
3c6a15ce98 fix(discord): make opus optional and log fallback 2026-02-22 18:47:09 +01:00
Vincent Koc
24fd8cbdc8 fix(auto-reply): preserve OpenRouter @preset model directives (#23769)
* Auto-reply: preserve OpenRouter @preset model directives

* Changelog: move OpenRouter preset fix into 2026.2.22 unreleased
2026-02-22 12:46:04 -05:00
大猫子
91944ede4c fix(cron): propagate auth-profile resolution to isolated sessions (#20624) (#20689) 2026-02-22 12:45:03 -05:00
Peter Steinberger
3a19b0201c test(installer): drop legacy gum env from docker smoke 2026-02-22 18:44:21 +01:00
Alex Zaytsev
1685a0dd12 fix: remove trailing newline from CLAUDE.md symlink target (#21160)
* fix: remove trailing newline from CLAUDE.md symlink target

* Dev tooling: prevent CLAUDE symlink newline regressions

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:40:06 -05:00
Aleksandrs Tihenko
c52b2ad5c3 fix(cache): inject cache_control into system prompt for OpenRouter Anthropic (#15151) (#17473)
* fix(cache): inject cache_control into system prompt for OpenRouter Anthropic

Add onPayload wrapper that injects cache_control: { type: "ephemeral" }
into the system/developer message content for OpenRouter requests routed
to Anthropic models. The system prompt is typically ~18k tokens and was
being re-processed on every request without caching.

Fixes #15151

* Changelog: add OpenRouter note for #17473

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:27:01 -05:00
Peter Steinberger
66529c7aa5 refactor(gateway): unify auth credential resolution 2026-02-22 18:23:13 +01:00
Joly0
ded9a59f78 OpenRouter: allow any model ID instead of restricting to static catalog (#14312)
* OpenRouter: allow any model ID instead of restricting to static catalog

OpenRouter models were restricted to a hardcoded prefix list in the internal model catalog, preventing use of newly added or less common models. This change makes OpenRouter work as the pass-through proxy it is -- any valid OpenRouter model ID now resolves dynamically.

Fixes https://github.com/openclaw/openclaw/issues/5241

Changes:
- Add OpenRouter as an implicit provider in resolveImplicitProviders so models.json is populated when an API key is detected (models-config.providers.ts)
- Add a pass-through fallback in resolveModel that creates OpenRouter models on-the-fly when they aren't pre-registered in the local catalog (
model.ts
)
- Remove the static prefix filter for OpenRouter/opencode in isModernModelRef (live-model-filter.ts)

* Apply requested change for maxTokens

* Agents: remove dead helper in live model filter

* Changelog: note Joly0/main OpenRouter fix

* Changelog: fix OpenRouter entry text

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:21:20 -05:00
zwffff
c543994e90 Default reasoning to on when model has reasoning: true (fix #22456) (#22513)
* Default reasoning to on when model has reasoning: true (fix #22456)

What: When a model is configured with reasoning: true in openclaw.json (e.g. OpenRouter x-ai/grok-4.1-fast), the session now defaults reasoningLevel to on if the user has not set it via /reasoning or session store.

Why: Users expected setting reasoning: true on the model to enable reasoning; previously only session/directive reasoningLevel was used and it always defaulted to off, so Think stayed off despite the model config.

* Chore: sync formatted files from main for CI

* Changelog: note zwffff/main OpenRouter fix

* Changelog: fix OpenRouter entry text

* Update msteams.md

* Update msteams.md

* Update msteams.md

---------

Co-authored-by: 曾文锋0668000834 <zeng.wenfeng@xydigit.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:19:36 -05:00
Vincent Koc
9ae08ce205 Memory: add Arabic query expansion stop words (#23717) 2026-02-22 12:17:47 -05:00
Peter Steinberger
8c71bbe1e1 docs(changelog): add memory remote-guard hardening notes 2026-02-22 18:15:35 +01:00
Peter Steinberger
eb041daee2 fix(memory): route batch APIs through guarded remote HTTP 2026-02-22 18:15:15 +01:00
Peter Steinberger
f87db7c627 fix(memory): enforce guarded remote policy for embeddings 2026-02-22 18:15:15 +01:00
Peter Steinberger
f6feb4144c refactor(memory): add guarded remote HTTP helper 2026-02-22 18:15:15 +01:00
Robby
99cfb3dab2 fix(openrouter): pass reasoning.effort based on thinking level (#14664) (#17236)
* fix(openrouter): pass reasoning.effort to OpenRouter API (#14664)

* Agents: pass thinkLevel to extra-params wrapper

* Changelog: note fix/openrouter-reasoning-effort-14664 OpenRouter fix

* Changelog: fix OpenRouter entry text

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:14:12 -05:00
Vincent Koc
ecf2cff9cd Update CHANGELOG.md 2026-02-22 12:12:28 -05:00
Peter Steinberger
296b19e413 test: dedupe gateway browser discord and channel coverage 2026-02-22 17:11:54 +00:00
Peter Steinberger
34ea33f057 refactor: dedupe core config and runtime helpers 2026-02-22 17:11:54 +00:00
Peter Steinberger
24ea941e28 test: dedupe auto-reply web and signal flows 2026-02-22 17:11:54 +00:00
Peter Steinberger
ad1072842e test: dedupe agent tests and session helpers 2026-02-22 17:11:54 +00:00
Mitsuyuki Osabe
415686244a feat: pass through OpenRouter provider routing params (#17148)
extraParams.provider was silently dropped by createStreamFnWithExtraParams().
This change injects it into model.compat.openRouterRouting so pi-ai's
buildParams includes params.provider in the API request body.

Enables OpenRouter provider routing options (only, order, allow_fallbacks,
data_collection, ignore, sort, quantizations) via model config:

```jsonc
"openrouter/model-name": {
  "params": {
    "provider": {
      "only": ["deepinfra", "fireworks"],
      "allow_fallbacks": false
    }
  }
}
```

Closes #10869

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)
2026-02-22 12:11:04 -05:00
Vincent Koc
3254c72d4b Update CHANGELOG.md 2026-02-22 12:09:19 -05:00
Omair Afzal
3891ba4bb5 fix(providers): preserve openrouter/ prefix for native models (#12942)
* fix(providers): preserve openrouter/ prefix for native models (#12924)

OpenRouter-native models like 'openrouter/aurora-alpha' need the full
'openrouter/<name>' as the model ID in API requests. The existing
parseModelRef() stripped the prefix, sending just 'aurora-alpha'
which OpenRouter rejects with 400.

Fix: normalizeProviderModelId() now re-adds the 'openrouter/' prefix
for models without a slash (native models), while passing through
external provider models (e.g. 'anthropic/claude-sonnet-4-5') as-is.

Closes #12924

* Changelog: add OpenRouter note for #12942

---------

Co-authored-by: Luna AI <luna@coredirection.ai>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:08:46 -05:00
Dan Dodson
4cad674387 fix: preserve stored provider in resolveSessionModelRef for vendor-prefixed models (#22753)
* fix: preserve stored provider in resolveSessionModelRef for vendor-prefixed models

When an OpenRouter model with a vendor prefix (e.g. "anthropic/claude-haiku-4.5")
was successfully used and persisted to the session entry, the next call to
resolveSessionModelRef would re-parse the model string through parseModelRef,
which splits on the first slash and incorrectly extracts "anthropic" as the
provider — discarding the stored "openrouter" provider entirely. This caused
subsequent requests to attempt direct Anthropic API calls with an OpenRouter
API key, producing "credit balance too low" billing errors.

The fix trusts the explicitly stored modelProvider on the session entry and
skips parseModelRef re-parsing when a provider is already recorded. parseModelRef
is still used as a fallback when no provider is stored on the entry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Changelog: add OpenRouter note for #22753

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-22 12:07:33 -05:00
Peter Steinberger
91cb28ecef perf(test): speed temp-path AST scan 2026-02-22 17:06:35 +00:00
Peter Steinberger
572daed456 test: trim duplicate async-search status reopen check 2026-02-22 17:06:35 +00:00
Peter Steinberger
35fecc4bee test: remove redundant runner ordering checks 2026-02-22 17:06:35 +00:00
Peter Steinberger
e38196d42c test: trim duplicate program smoke onboarding coverage 2026-02-22 17:06:35 +00:00
Peter Steinberger
c6b94f2652 test: speed up skills download tar traversal fixture 2026-02-22 17:06:35 +00:00
Peter Steinberger
bd6be417e4 test: trim duplicate smoke and embedded runner cases 2026-02-22 17:06:35 +00:00
Peter Steinberger
ee7a43b895 test: replace slow gateway SIGTERM integration coverage 2026-02-22 17:06:35 +00:00
Peter Steinberger
d01cc69ef0 test: tighten process timeout fixtures 2026-02-22 17:06:35 +00:00
Peter Steinberger
b1a97e77ca test: tighten bash timeout poll upper bound 2026-02-22 17:06:35 +00:00
Peter Steinberger
c5904da85a test: trim bash tool timing constants 2026-02-22 17:06:35 +00:00
Peter Steinberger
c23cdf67d7 test: speed up qmd boot retry lock test 2026-02-22 17:06:35 +00:00
Peter Steinberger
68b9b44498 test: reduce bash background abort wait constants 2026-02-22 17:06:35 +00:00
Peter Steinberger
dd4495e23a test: optimize temp path guard scan prefilter 2026-02-22 17:06:35 +00:00
Peter Steinberger
7bf719fe85 test: narrow weak-random rg scan globs 2026-02-22 17:06:35 +00:00
Peter Steinberger
7626503965 test: reduce web auto-reply watchdog timer churn 2026-02-22 17:06:35 +00:00
Peter Steinberger
089ee242bc test: precompute skills download tar fixture and dedupe setup 2026-02-22 17:06:35 +00:00
Peter Steinberger
2b74e5f66d test: reduce bash tool suite sleep durations 2026-02-22 17:06:35 +00:00
Peter Steinberger
47514e35a2 test: dedupe pi embedded runner setup and orphan case 2026-02-22 17:06:35 +00:00
Peter Steinberger
f3ba3fe8dc test: isolate skills-install temp home env 2026-02-22 17:06:35 +00:00
Peter Steinberger
992fc9cf4e test: trim cli program test bootstrap overhead 2026-02-22 17:06:35 +00:00
Peter Steinberger
3046fa31e8 test: isolate skills suite env and trim scan overhead 2026-02-22 17:06:35 +00:00
Peter Steinberger
6cdeb62a01 test: trim gateway sigterm bootstrap imports 2026-02-22 17:06:35 +00:00
Peter Steinberger
407f7017ec test: cache plugin install archive fixtures 2026-02-22 17:06:35 +00:00
Peter Steinberger
1d2f305117 style: format skills install download test 2026-02-22 17:06:35 +00:00
Peter Steinberger
6cd12ca1ce test: merge download archive safety suites 2026-02-22 17:06:35 +00:00
Peter Steinberger
07514361d7 test: speed up weak random guardrail scan 2026-02-22 17:06:35 +00:00
Peter Steinberger
13d3758efd test: preload doctor command in migration suites 2026-02-22 17:06:34 +00:00
Peter Steinberger
c42b0b2dfc test: preload sandbox explain command module in suite 2026-02-22 17:06:34 +00:00
Peter Steinberger
0b13a0286e test: preload bash exec path tool module in suite 2026-02-22 17:06:34 +00:00
Peter Steinberger
6042075bdf test: preload safe-bins tool module in suite 2026-02-22 17:06:34 +00:00
Peter Steinberger
71747a7688 test: preload onboarding command modules in hot suites 2026-02-22 17:06:34 +00:00
Peter Steinberger
b6ac0eef5d test: trim gateway fixture sizes and preload message command 2026-02-22 17:06:34 +00:00
Peter Steinberger
8cc744ef1f fix(logging): cap file logs with configurable maxFileBytes
Co-authored-by: Xinhua Gu <562450+xinhuagu@users.noreply.github.com>
2026-02-22 17:58:51 +01:00
Peter Steinberger
795db98f6a fix(telegram): notify users on media download failures
Co-authored-by: Artale <117890364+arosstale@users.noreply.github.com>
2026-02-22 17:54:16 +01:00
Peter Steinberger
d0e6763263 fix(telegram): wire webhookPort through config and startup
Co-authored-by: xrf9268-hue <244283935+xrf9268-hue@users.noreply.github.com>
2026-02-22 17:54:16 +01:00
Peter Steinberger
5069250faf fix(telegram): clear webhook state before polling startup
Co-authored-by: Peter Machona <7957943+chilu18@users.noreply.github.com>
2026-02-22 17:54:16 +01:00
Peter Steinberger
81384daeb4 fix(telegram): harden polling retry setup and teardown order
Co-authored-by: Cklee <99405438+liebertar@users.noreply.github.com>
Co-authored-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-02-22 17:54:16 +01:00
Peter Steinberger
1a9b5840d2 fix(telegram): keep webhook monitor alive until abort
Co-authored-by: Evgeny Zislis <7056+kesor@users.noreply.github.com>
2026-02-22 17:54:16 +01:00
Peter Steinberger
e58054b85c docs(telegram): align Node22 network defaults and setup guidance 2026-02-22 17:54:16 +01:00
Peter Steinberger
e9ed688c2c fix(net): enable family fallback for pinned SSRF dispatcher 2026-02-22 17:54:15 +01:00
Peter Steinberger
4d0ca7c315 fix(telegram): restart stalled polling after unhandled network errors 2026-02-22 17:54:15 +01:00
Peter Steinberger
824d1e095b fix(infra): treat undici fetch failed as transient unhandled rejection 2026-02-22 17:54:15 +01:00
Peter Steinberger
dbc1ed8933 fix(update): run auto-update via runtime argv and keep it independent of checkOnStart 2026-02-22 17:41:05 +01:00
Vincent Koc
35b162af76 Memory: add Spanish and Portuguese query expansion stop words (#23710) 2026-02-22 11:26:12 -05:00
Peter Steinberger
f14ebd743c refactor(security): unify local-host and tailnet CIDR checks 2026-02-22 17:20:27 +01:00
Vincent Koc
21cbf59509 feat(memory): add Japanese query expansion support for FTS (#23156)
* Memory: add Japanese query expansion support

* Docs/Changelog: credit Japanese FTS update
2026-02-22 11:19:20 -05:00
Peter Steinberger
f442a3539f feat(update): add core auto-updater and dry-run preview 2026-02-22 17:11:36 +01:00
Nikolay Petrov
13690d406a Telegram: coalesce forwarded text+media bursts into one inbound turn (#19476)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 09e0b4e9bd
Co-authored-by: napetrov <18015221+napetrov@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 21:41:09 +05:30
Peter Steinberger
333fbb8634 refactor(net): consolidate IP checks with ipaddr.js 2026-02-22 17:02:44 +01:00
Marcus Castro
337eef55d7 fix(telegram): link forwarded messages with comments (#9720)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 5f81061b5f
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 21:23:56 +05:30
Peter Steinberger
40a68a8936 docs: add concise gh search playbook to AGENTS 2026-02-22 16:41:17 +01:00
Ayaan Zaidi
6268ed57ea fix(agents): stop param shadowing in auth failure marker 2026-02-22 21:00:17 +05:30
Marcus Castro
ace8357149 fix(telegram): skip failed photo downloads in media group instead of dropping entire group (#20598)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4a9c5f7af7
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 20:57:06 +05:30
Peter Steinberger
9363c320d8 fix(security): harden shell env fallback startup env handling 2026-02-22 16:06:27 +01:00
Peter Steinberger
ab1840b881 docs(changelog): credit SSRF report in unreleased notes 2026-02-22 16:02:49 +01:00
Peter Steinberger
a0d0104a86 test: speed up signal reconnect and temp path guard scans 2026-02-22 14:44:19 +00:00
Peter Steinberger
142c0a7f7d refactor: extract gateway transcript append helper 2026-02-22 14:44:19 +00:00
Peter Steinberger
8e6b465fa8 test: speed up agent command suite with lightweight runtime mocks 2026-02-22 14:44:19 +00:00
Peter Steinberger
1cf8f41134 test: dedupe expensive web auto-reply compression coverage 2026-02-22 14:44:19 +00:00
Peter Steinberger
d0b59270a7 refactor: dedupe auth-profile failure marking and rotation test setup 2026-02-22 15:44:10 +01:00
Peter Steinberger
44dfbd23df fix(ssrf): centralize host/ip block checks 2026-02-22 15:41:41 +01:00
Peter Steinberger
39be5e44df refactor: split config reload flow and test harness 2026-02-22 15:38:23 +01:00
Glucksberg
53adae9cec fix(telegram): add dnsResultOrder=ipv4first default on Node 22+ to fix fetch failures (#5405)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 71366e9532
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 20:07:51 +05:30
Peter Steinberger
4e65e61612 fix: retry missing config snapshots before skip (#23343) (thanks @lbo728) 2026-02-22 15:34:46 +01:00
lbo728
aaa9bd0f1c fix(config-reload): skip reload when config file is not found
When a config file is written atomically (tmp → rename), chokidar can
fire an 'unlink' event for the temporary removal of the destination file
before the rename completes. runReload() would then call readSnapshot(),
which returns { exists: false, valid: true, config: {} } — an empty
config that looks valid — causing diffConfigPaths() to find many changes
and triggering an unnecessary SIGUSR1 restart.

The restarted gateway process then fails to find the config file (still
in the middle of the write) and enters a crash loop with:
  'Missing config. Run openclaw setup...'

Fix: guard against exists=false before the existing valid=false check,
so mid-write snapshots are silently skipped rather than treated as a
config wipe.

Fixes #23321
2026-02-22 15:34:46 +01:00
Peter Steinberger
3e2849c578 fix: align timeout cooldown behavior docs/tests (#22622) (thanks @vageeshkumar) 2026-02-22 15:34:20 +01:00
Vageesh Kumar
71d0b86352 fix(agents): skip auth profile cooldown for timeout failures
A timeout is model/network-specific, not an auth issue. Marking the
auth profile as failed on timeout poisons fallback models on the same
provider (e.g. gpt-5.3 timeout would block gpt-5.2 via shared profile
cooldown). The prompt-phase path already guards against this; this
aligns the post-response timeout path to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:34:20 +01:00
Val Alexander
c5be45dfd2 test: skip CLI auto-detect e2e tests on Windows (#23626) 2026-02-22 08:31:40 -06:00
Peter Steinberger
4c355a28a3 refactor: centralize tool-error visibility policy 2026-02-22 15:30:53 +01:00
Peter Steinberger
ac3ac6a83a refactor(signal): extract rpc parse helper and validate response envelope 2026-02-22 15:29:56 +01:00
Peter Steinberger
a5e2bd4eaa docs: document verbose-gated tool error details 2026-02-22 15:26:48 +01:00
Peter Steinberger
835be4392e fix: gate tool error details behind verbose 2026-02-22 15:26:47 +01:00
Peter Steinberger
184844e50c fix: add signal rpc malformed-json regression test (#22995) (thanks @adhitShet) 2026-02-22 15:23:37 +01:00
adhitShet
4b78e91acd fix(signal): guard JSON.parse of Signal RPC response with try-catch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:23:37 +01:00
Peter Steinberger
7d7297f57f fix: downgrade telegram autoSelectFamily log to debug 2026-02-22 15:21:23 +01:00
Peter Steinberger
f5ede0f2bd test: stabilize acp cwd prefix assertions across env leakage 2026-02-22 14:18:44 +00:00
Peter Steinberger
b0a8b3bebb test: share fast-path mocks for targeted doctor suites 2026-02-22 14:18:44 +00:00
Peter Steinberger
2c0b72acb8 test: speed up slow media and synology suites 2026-02-22 14:18:44 +00:00
Peter Steinberger
32c33f4faa test: isolate doctor allowFrom migration assertions from unrelated checks 2026-02-22 14:18:44 +00:00
tyler
9b23e5ce1f test: fix flaky auth tests when OPENCLAW_GATEWAY_TOKEN is present 2026-02-22 15:17:37 +01:00
Peter Steinberger
9f2b25426b test(core): increase coverage for sessions, auth choice, and model listing 2026-02-22 14:08:51 +00:00
Peter Steinberger
d116bcfb14 refactor(runtime): consolidate followup, gateway, and provider dedupe paths 2026-02-22 14:08:51 +00:00
Peter Steinberger
38752338dc refactor(tui): dedupe handlers and formatter test setup 2026-02-22 14:08:51 +00:00
Peter Steinberger
66f814a0af refactor(channels): dedupe plugin routing and channel helpers 2026-02-22 14:08:51 +00:00
Peter Steinberger
7abae052f9 chore(skills): remove bundled food-order skill 2026-02-22 15:06:27 +01:00
Val Alexander
e697ec273a UI: polish dashboard — agents overview, chat toolbar, debug & login UX (#23553)
* UI: polish dashboard — agents overview, chat toolbar, debug simplification, login UX

* fix(ui): restore chat draft ordering, remove extra toolbar buttons

* UI: replace agent avatar fallback with lobster emoji

* style(ui): update layout styles for sidebar and shell, adjusting navigation widths for improved responsiveness

* feat(ui): implement sidebar resizing functionality and enhance navigation with new search and sorting features for sessions

* fix(ui): update references from ClawDash to OpenClaw in checklist and dashboard header

* style(ui): adjust sidebar minimum width and add responsive behavior for narrow states

* UI: minimal chat agent bar — remove sessions panel, strip chrome

* style(ui): update light theme colors and add ambient gradient for Luxe Cream & Coral

* UI: replace sparkle with OpenClaw lobster logo in chat

* style(ui): rename theme toggle to theme select and update related styles; adjust layout and spacing for agents and chat components

* style(ui): enhance agents panel layout with grid system, update toolbar styles, and refine usage chart presentation

* style(ui): adjust sessions table column width and refine agent model fields layout for better responsiveness

* style(ui): refine component styles for improved layout and responsiveness; adjust gradients, spacing, and element alignment across chat and agent interfaces

* ui: align chat-controls session container

* ui: enlarge agent controls for better touch targets

* ui: pass basePath to avatar renderer in grouped chat

* ui: formatting fixups from pre-commit hooks

* style(ui): update layout and spacing for chat controls; enhance select component styles and improve responsiveness

* UI: tighten chat header spacing and icon sizes

* UI: widen chat attachment gap

* style(ui): refine chat header layout and adjust icon sizes for improved visual consistency

* style(ui): enhance component styles and layout; introduce new inline field styles, update overview card design, and improve session filters for better usability

* style(ui): improve CSS formatting and consistency across components; adjust gradients, spacing, and layout for better readability and visual appeal

* fix(ui): correct rendering of empty state in overview cards by replacing 'nothing' with an empty string
2026-02-22 07:56:17 -06:00
Peter Steinberger
e578e8379c fix: align agent panel UI props after merge 2026-02-22 13:47:31 +00:00
Peter Steinberger
013299b001 perf: lazy-load non-interactive onboarding heavy paths 2026-02-22 13:47:25 +00:00
Peter Steinberger
adfbbcf1f6 chore: merge origin/main into main 2026-02-22 13:42:52 +00:00
Peter Steinberger
06d93cc12c test: dedupe doctor routing allowFrom migration coverage 2026-02-22 13:41:00 +00:00
Peter Steinberger
1becebe188 fix: harden session lock contention and cleanup 2026-02-22 13:40:55 +00:00
Val Alexander
3ea3184efe refactor(ui): implement agent avatar resolution and logo fallback in agent rendering 2026-02-22 07:39:54 -06:00
Val Alexander
284961108a style(ui): update component styles with spacing, padding, and typography adjustments for improved layout 2026-02-22 07:39:54 -06:00
Val Alexander
fb577d2482 style(ui): refine layout styles with adjustments to spacing, padding, and typography 2026-02-22 07:39:53 -06:00
Onur Solmaz
f39a66de27 docs: make subagents thread guidance channel-first (#23589) (thanks @osolmaz) 2026-02-22 14:39:40 +01:00
Onur
3308c86002 docs: keep channel names only in thread-support list 2026-02-22 14:39:40 +01:00
Onur
418e4e32c9 docs: clarify thread-bound subagents are Discord-only 2026-02-22 14:39:40 +01:00
Onur
c952334808 docs: list thread supporting channels in subagents guide 2026-02-22 14:39:40 +01:00
Onur
0b9b9d4301 docs: make subagents thread guidance channel-first 2026-02-22 14:39:40 +01:00
Peter Steinberger
648d2daf67 test: drop duplicate timeout-fallback e2e and trim onboarding auth overlap 2026-02-22 13:33:40 +00:00
Peter Steinberger
7a2b05314a test: speed up onboarding provider auth and temp-path guard scans 2026-02-22 13:24:59 +00:00
Peter Steinberger
494bb685f8 test: merge signal typing-read-receipt coverage into inbound contract suite 2026-02-22 13:24:53 +00:00
Peter Steinberger
a395479d8b test: merge signal sender-prefix coverage into typing suite 2026-02-22 13:12:57 +00:00
Peter Steinberger
83597572df test: speed up thread-bindings shared-state loader test 2026-02-22 13:09:59 +00:00
Peter Steinberger
d236ded43f test: speed up non-interactive gateway onboarding suite 2026-02-22 13:08:34 +00:00
Peter Steinberger
9e868dcf5a test: remove redundant channels smoke parse case 2026-02-22 12:56:18 +00:00
Peter Steinberger
5e62d0105b test: trim smoke duplicates and reuse telegram bot setup 2026-02-22 12:55:27 +00:00
Peter Steinberger
27053826e5 test: close bootstrap ws in approval bypass suite 2026-02-22 12:55:22 +00:00
Peter Steinberger
83a2926328 test: align remaining trigger configs with fast harness defaults 2026-02-22 12:43:10 +00:00
Peter Steinberger
829236afa7 test: reuse trigger harness defaults in custom configs 2026-02-22 12:41:37 +00:00
Peter Steinberger
2c40a20737 test: trim background hold duration in abort coverage 2026-02-22 12:38:57 +00:00
Peter Steinberger
00eb2541dc test: shorten idle child timers in timeout assertions 2026-02-22 12:37:49 +00:00
Peter Steinberger
5b23159c4c test: create homedir before sandbox image mkdtemp 2026-02-22 12:35:38 +00:00
Peter Steinberger
96515a5729 test: merge duplicate read-tool content coverage cases 2026-02-22 12:32:05 +00:00
Peter Steinberger
22ff83c3cf test: remove fixed delay from cron concurrency assertion 2026-02-22 12:30:43 +00:00
Peter Steinberger
c8a4977378 test: replace mtime sleep with explicit utimes bump 2026-02-22 12:29:53 +00:00
Peter Steinberger
8e29160eaa test: remove fixed waits from tool-result ordering tests 2026-02-22 12:29:08 +00:00
Peter Steinberger
dc356ae1c2 test: remove duplicate workspace path-resolution case 2026-02-22 12:27:55 +00:00
Peter Steinberger
c7a4346e4d test: remove sharp dependency from read-tool metadata test 2026-02-22 12:27:10 +00:00
Peter Steinberger
60a0291bf8 test: dedupe workspace path-resolution scenarios 2026-02-22 12:25:57 +00:00
Peter Steinberger
07527e22ce refactor(auth-profiles): centralize active-window logic + strengthen regression coverage 2026-02-22 13:23:19 +01:00
Peter Steinberger
c61c9e121a test: relax node connect challenge timeout in approval suite 2026-02-22 12:22:53 +00:00
Peter Steinberger
1152b25866 fix(gateway): guard trim crashes in subagent flow 2026-02-22 13:21:26 +01:00
Val Alexander
52d1ece262 style(ui): enhance agent model layout with margin adjustments and flexbox for actions 2026-02-22 06:21:12 -06:00
Val Alexander
1c86a1b337 refactor(ui): simplify agent overview component by removing unused identity fields and enhancing fallback display 2026-02-22 06:21:12 -06:00
Peter Steinberger
eec3182cbb fix(utils): guard resolveUserPath for missing workspace input 2026-02-22 13:19:25 +01:00
Peter Steinberger
0d0f4c6992 refactor(exec): centralize safe-bin policy checks 2026-02-22 13:18:25 +01:00
Peter Steinberger
3a6e0e70f6 test: make gateway connectReq timeout configurable 2026-02-22 12:18:21 +00:00
Peter Steinberger
89e2928204 test: speed up trigger harness queue defaults 2026-02-22 12:18:15 +00:00
Peter Steinberger
dc6440b9f3 test: harden claude usage fallback assertions 2026-02-22 12:18:10 +00:00
Peter Steinberger
bcad4f67a2 fix(gateway): unify listen startup log across bind hosts 2026-02-22 13:17:25 +01:00
Artale
51e9c54f09 fix(agents): skip bootstrap files with undefined path (#22698)
* fix(agents): skip bootstrap files with undefined path

buildBootstrapContextFiles() called file.path.replace() without checking
that path was defined. If a hook pushed a bootstrap file using 'filePath'
instead of 'path', the function threw TypeError and crashed every agent
session — not just the misconfigured hook.

Fix: add a null-guard before the path.replace() call. Files with undefined
path are skipped with a warning so one bad hook can't take down all agents.

Also adds a test covering the undefined-path case.

Fixes #22693

* fix: harden bootstrap path validation and report guards (#22698) (thanks @arosstale)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-22 13:17:07 +01:00
Val Alexander
45d7776697 fix(ui): update topbar styles for improved layout and active state 2026-02-22 06:14:53 -06:00
Val Alexander
7ba970938e fix(ui): add label for stream mode in app render 2026-02-22 06:14:53 -06:00
Peter Steinberger
d2542d9d37 chore(gateway): cover denied notifyOnExit path and clarify help 2026-02-22 13:14:08 +01:00
zerone0x
6fde581a25 test(node): add coverage for notifyOnExit=false suppressing exec events 2026-02-22 13:14:07 +01:00
zerone0x
0f7b259cca fix(node): respect tools.exec.notifyOnExit for node exec events
Node exec events (exec.started, exec.finished, exec.denied) now check
the tools.exec.notifyOnExit config setting before generating system
event notifications. When notifyOnExit is false, all node exec event
notifications are suppressed.

This makes node exec behavior consistent with gateway exec, which
already respects this setting.

Fixes #20193

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 13:14:07 +01:00
Peter Steinberger
7c3c406a35 fix: keep auth-profile cooldown windows immutable in-window (#23536) (thanks @arosstale) 2026-02-22 13:14:02 +01:00
artale
dc69610d51 fix(auth-profiles): never shorten cooldown deadline on retry
When the backoff saturates at 60 min and retries fire every 30 min
(e.g. cron jobs), each failed request was resetting cooldownUntil to
now+60m.  Because now+60m < existing deadline, the window kept getting
renewed and the profile never recovered without manually clearing
usageStats in auth-profiles.json.

Fix: only write a new cooldownUntil (or disabledUntil for billing) when
the new deadline is strictly later than the existing one.  This lets the
original window expire naturally while still allowing genuine backoff
extension when error counts climb further.

Fixes #23516

[AI-assisted]
2026-02-22 13:14:02 +01:00
Val Alexander
bec059f7b2 fix(ui): ensure correct draft value in chat input handling 2026-02-22 06:11:24 -06:00
Peter Steinberger
376eb6e99b docs(changelog): note safe-bin profile hardening 2026-02-22 13:03:05 +01:00
Peter Steinberger
47c3f742b6 fix(exec): require explicit safe-bin profiles 2026-02-22 12:58:55 +01:00
Peter Steinberger
c7ff12ef29 fix: use effective home for legacy zai auth fallback 2026-02-22 11:58:14 +00:00
Peter Steinberger
09017b77a2 test: tighten e2e runner defaults 2026-02-22 11:58:07 +00:00
Peter Steinberger
760ad5dfb3 test: move local integration suites out of e2e 2026-02-22 11:54:01 +00:00
Peter Steinberger
99f05ba258 test: move gateway sigterm suite out of e2e 2026-02-22 11:53:03 +00:00
Peter Steinberger
5ffcc4b735 test: fix logger stub typing in directive-tags test 2026-02-22 11:52:18 +00:00
Peter Steinberger
5636e6257c test: make gateway sigterm e2e node25-compatible 2026-02-22 11:51:43 +00:00
Val Alexander
d055b948fb fix(ui): stop auth failure reconnect loop, surface login gate 2026-02-22 05:51:15 -06:00
Val Alexander
79ae8148f7 fix(ui): stop reconnect loop on auth failure, surface login gate 2026-02-22 05:51:06 -06:00
Peter Steinberger
9f80ac47ee test: move sessions_send suite out of e2e 2026-02-22 11:50:22 +00:00
Peter Steinberger
3f0ab76422 test: stabilize remaining e2e gateway suites 2026-02-22 11:48:53 +00:00
Peter Steinberger
aa14835607 test: reclassify gateway local suites from e2e 2026-02-22 11:48:46 +00:00
Peter Steinberger
e80c803fa8 fix(security): block shell env allowlist bypass in system.run 2026-02-22 12:47:05 +01:00
Peter Steinberger
d5bb9f026e fix: add changelog entry for remote ws onboarding hardening (#23476) (thanks @bmendonca3) 2026-02-22 12:46:20 +01:00
Brian Mendonca
8a3d04c19c Gateway UX: harden remote ws guidance and onboarding defaults 2026-02-22 12:46:20 +01:00
Peter Steinberger
6fda04e938 refactor: tighten onboarding dmScope typing and docs links 2026-02-22 12:46:09 +01:00
Peter Steinberger
29cc7f431f test: share runtime scan filters and cached test scans 2026-02-22 12:44:44 +01:00
Peter Steinberger
6dd36a6b77 refactor(channels): reuse runtime group policy helpers 2026-02-22 12:44:23 +01:00
Val Alexander
13944f773f UI: use gateway token for login gate auth 2026-02-22 05:40:35 -06:00
Peter Steinberger
3a65e4b523 test: make snapshot env override assertion independent of host env 2026-02-22 12:40:30 +01:00
Peter Steinberger
65dccbdb4b fix: document onboarding dmScope default as breaking change (#23468) (thanks @bmendonca3) 2026-02-22 12:36:49 +01:00
Brian Mendonca
8f0b2b84e7 Onboarding: default dmScope to per-channel-peer 2026-02-22 12:36:49 +01:00
Peter Steinberger
85e5ed3f78 refactor(channels): centralize runtime group policy handling 2026-02-22 12:35:41 +01:00
Peter Steinberger
a4607277a9 test: consolidate sessions_spawn and guardrail helpers 2026-02-22 12:34:55 +01:00
Peter Steinberger
62ddc1ef7a test: move gateway client watchdog suite out of e2e 2026-02-22 11:34:50 +00:00
Val Alexander
59191474eb docs(ui): update checklist for 5-theme setup 2026-02-22 05:34:42 -06:00
Val Alexander
1e4e24852a UI: remove OpenAI/Ember theme, reduce to 5 themes 2026-02-22 05:34:42 -06:00
Peter Steinberger
38cd30836d test: reclassify openresponses parity suite 2026-02-22 11:34:15 +00:00
Peter Steinberger
868c0e4c56 test: move gateway server integration suites out of e2e 2026-02-22 11:33:27 +00:00
Peter Steinberger
6c61616d51 test: move gateway rpc/local suites out of e2e 2026-02-22 11:31:42 +00:00
Peter Steinberger
7fdf54f078 test: move cli local suites out of e2e 2026-02-22 11:30:29 +00:00
Peter Steinberger
0a758dc710 test(cron): improve fire-and-forget harness coverage 2026-02-22 11:29:31 +00:00
Peter Steinberger
c343132dbb fix(agents): harden bash tool and reply directive handling 2026-02-22 11:29:31 +00:00
Peter Steinberger
a4981efae3 fix(discord): improve outbound send consistency 2026-02-22 11:29:31 +00:00
Peter Steinberger
0f989d3109 fix(gateway): tighten openai-http edge handling 2026-02-22 11:29:31 +00:00
Peter Steinberger
05358173da fix(line): harden outbound send behavior 2026-02-22 11:29:31 +00:00
Peter Steinberger
32a1273d82 refactor(onboarding): dedupe channel allowlist flows 2026-02-22 11:29:31 +00:00
Peter Steinberger
49648daec0 fix(zalouser): normalize send and onboarding flows 2026-02-22 11:29:31 +00:00
Peter Steinberger
5c7ab8eae3 test(zalo): broaden webhook monitor coverage 2026-02-22 11:29:31 +00:00
Peter Steinberger
081ab9c99d fix(voice-call): tighten manager outbound behavior 2026-02-22 11:29:31 +00:00
Peter Steinberger
8c1afc4b63 fix(msteams): improve graph user and token parsing 2026-02-22 11:29:31 +00:00
Peter Steinberger
e80c66a571 fix(mattermost): refine probe and onboarding flows 2026-02-22 11:29:31 +00:00
Peter Steinberger
0a421d7409 test(line): improve logout scenario coverage 2026-02-22 11:29:31 +00:00
Peter Steinberger
5574eb6b35 fix(feishu): harden onboarding and webhook validation 2026-02-22 11:29:31 +00:00
Peter Steinberger
9e6125ea2f test(discord): stabilize subagent hook coverage 2026-02-22 11:29:31 +00:00
Peter Steinberger
5056f4e142 fix(bluebubbles): tighten chat target handling 2026-02-22 11:29:31 +00:00
Peter Steinberger
b36e7da07d test: move non-interactive onboarding suites out of e2e 2026-02-22 11:29:13 +00:00
Peter Steinberger
4c6e7c4fe0 test: reclassify agent command suite out of e2e 2026-02-22 11:28:45 +00:00
Peter Steinberger
50c7aef22f test: stabilize session lock tests and move out of e2e 2026-02-22 11:28:20 +00:00
Peter Steinberger
ad404c9626 fix: align markdown code renderer with marked token typing 2026-02-22 12:27:48 +01:00
Val Alexander
944d2b826c docs(ui): add dashboard verification checklist 2026-02-22 05:26:57 -06:00
Peter Steinberger
9f2444314d test: stabilize agent embedded-run mocks 2026-02-22 11:25:59 +00:00
Peter Steinberger
26763d1910 fix: resolve extension type errors and harden probe mocks 2026-02-22 12:25:58 +01:00
Val Alexander
3bbbe33a1b UI: gateway dashboard with glassmorphism theme system
Add a full-featured gateway dashboard UI built on Lit web components.

Shell & plumbing:
- App shell with router, controllers, and dependency wiring
- Login gate, i18n keys, and base layout scaffolding

Styles & theming:
- Base styles, chat styles, and responsive layout CSS
- 6-theme glassmorphism system (Obsidian, Aurora, Solar, etc.)
- Glass card, glass panel, and glass input components
- Favicon logo in expanded sidebar header

Views & features:
- Overview with attention cards, event log, quick actions, and log tail
- Chat view with markdown rendering, tool-call collapse, and delete support
- Command palette with fuzzy search
- Agent overview with config display, slash commands, and sidebar filtering
- Session list navigation and agent selector

Privacy & polish:
- Redact toggle with stream-mode default
- Blur host/IP in Connected Instances with reveal toggle
- Sensitive config value masking with count badge
- Card accent borders, hover lift effects, and responsive grid
2026-02-22 05:24:54 -06:00
Peter Steinberger
401106b963 fix: harden flaky tests and cover native google thought signatures (#23457) (thanks @echoVic) 2026-02-22 12:24:53 +01:00
echoVic
9176571ec1 fix(gemini): sanitize thoughtSignatures for native Google provider
Native Google Gemini provider was accumulating 2K-8K tokens of Base64
thoughtSignature blobs per turn, causing premature context overflow.

The sanitizer was only enabled for OpenRouter Gemini, not native Google.

Fixes #23392
2026-02-22 12:24:53 +01:00
Peter Steinberger
bf52273a58 test: harden flaky timeout-sensitive tests 2026-02-22 12:21:19 +01:00
Peter Steinberger
42f62821db fix: include shared runtime group-policy helper and coverage (#23367) (thanks @bmendonca3) 2026-02-22 12:21:04 +01:00
Peter Steinberger
777817392d fix: fail closed missing provider group policy across message channels (#23367) (thanks @bmendonca3) 2026-02-22 12:21:04 +01:00
Peter Steinberger
78c3c2a542 fix: stabilize flaky tests and sanitize directive-only chat tags 2026-02-22 12:19:33 +01:00
Peter Steinberger
7d09a9e74d test: update agent tool assertions and reclassify suites 2026-02-22 11:18:50 +00:00
Brian Mendonca
3700151ec0 Channels: fail closed when Slack/Discord config is missing 2026-02-22 12:18:43 +01:00
Peter Steinberger
fcb86408fd test: move embedded and tool agent suites out of e2e 2026-02-22 11:17:47 +00:00
Peter Steinberger
11546b1177 test(auth-choice): expand api provider dedupe coverage 2026-02-22 11:16:59 +00:00
Peter Steinberger
e441390fd1 test: reclassify agent local suites out of e2e 2026-02-22 11:16:37 +00:00
Peter Steinberger
fc60f4923a refactor(auth-choice): unify api-key resolution flows 2026-02-22 11:16:30 +00:00
Peter Steinberger
e2c7cf2f1a test: reclassify doctor command suites out of e2e 2026-02-22 11:12:48 +00:00
Peter Steinberger
895e6c4b9c test: move onboarding and sandbox command suites out of e2e 2026-02-22 11:10:05 +00:00
Peter Steinberger
08a5cba8af test: reclassify command config and channels suites 2026-02-22 11:09:43 +00:00
Ayaan Zaidi
8e00965618 test: use real SubsystemLogger in directive-tags test 2026-02-22 16:39:11 +05:30
Peter Steinberger
296b3f49ef refactor(bluebubbles): centralize private-api status handling 2026-02-22 12:08:41 +01:00
Peter Steinberger
817ca75cba test: move command status and health suites out of e2e 2026-02-22 11:07:46 +00:00
Peter Steinberger
ec36dd81a9 test: reclassify command helper suites out of e2e 2026-02-22 11:07:07 +00:00
Yuzuru Suzuki
6f7e5f92c3 fix: add operator.read and operator.write to default CLI scopes (#22582)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8569fc88c9
Co-authored-by: YuzuruS <1485195+YuzuruS@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 16:36:18 +05:30
Peter Steinberger
ec0081ce9a test: move hooks and plugin local suites out of e2e 2026-02-22 11:05:53 +00:00
Peter Steinberger
4a2492496e test: move browser and web auto-reply local suites out of e2e 2026-02-22 11:05:26 +00:00
Peter Steinberger
585a143f21 test: reclassify config and channel monitor behavior suites 2026-02-22 11:04:58 +00:00
Peter Steinberger
2d133d3ec2 test: reclassify auto-reply behavior suites out of e2e 2026-02-22 11:04:10 +00:00
Peter Steinberger
b77e53da67 refactor(session): centralize transcript path option resolution 2026-02-22 12:02:38 +01:00
Peter Steinberger
1ad284a85f test: move local cli and config scenario suites out of e2e 2026-02-22 10:58:04 +00:00
Peter Steinberger
713e2928b2 test: move duplicate local scenario suites out of agents e2e 2026-02-22 10:56:58 +00:00
Peter Steinberger
bfada9e425 test: move more local agents helper suites out of e2e 2026-02-22 10:55:22 +00:00
Peter Steinberger
4267fc8593 test: reclassify pi embedded helper suites out of agents e2e 2026-02-22 10:53:50 +00:00
Peter Steinberger
adace58505 test: reclassify local helper suites out of agents e2e 2026-02-22 10:53:40 +00:00
Peter Steinberger
b98d3330f6 docs: update pty supervision test command paths 2026-02-22 10:48:37 +00:00
Peter Steinberger
1d4e9ad8d1 test: reclassify remaining bash suites as unit tests 2026-02-22 10:48:32 +00:00
Peter Steinberger
37f12eb7ee fix: align BlueBubbles private-api null fallback + warning (#23459) (thanks @echoVic) 2026-02-22 11:47:57 +01:00
echoVic
888b6bc948 fix(bluebubbles): treat null privateApiStatus as disabled, not enabled
Bug: privateApiStatus cache expires after 10 minutes, returning null.
The check '!== false' treats null as truthy, causing 500 errors when
trying to use Private API features that aren't actually available.

Root cause: In JavaScript, null !== false evaluates to true.

Fix: Changed all checks from '!== false' to '=== true', so null (cache
expired/unknown) is treated as disabled (safe default).

Files changed:
- extensions/bluebubbles/src/send.ts (line 376)
- extensions/bluebubbles/src/monitor-processing.ts (line 423)
- extensions/bluebubbles/src/attachments.ts (lines 210, 220)

Fixes #23393
2026-02-22 11:47:57 +01:00
Peter Steinberger
ab38e1e6b2 test: reclassify image tool suite as unit test 2026-02-22 10:47:16 +00:00
Peter Steinberger
812bf7c8e1 fix: add bindings comment regression test (#23458) (thanks @echoVic) 2026-02-22 11:47:11 +01:00
echoVic
56f01bc493 fix(config): add missing comment field to BindingsSchema
Strict validation (added in d1e9490f9) rejects the legitimate 'comment'
field on bindings. This field is used for annotations in config files.

Changes:
- BindingsSchema: added comment: z.string().optional()
- AgentBinding type: added comment?: string

Fixes #23385
2026-02-22 11:47:11 +01:00
Peter Steinberger
aa487bd4f3 test: reclassify bash pty suites as unit tests 2026-02-22 10:47:10 +00:00
Glucksberg
2739328508 fix(telegram): classify undici fetch errors as recoverable for retry (#16699)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 67b5bce44f
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 16:16:11 +05:30
Peter Steinberger
3c9f98452e test: reclassify tool-result persist hook suite as unit test 2026-02-22 10:46:02 +00:00
Peter Steinberger
047e18693e test: reclassify exec approval-id suite as unit test 2026-02-22 10:45:23 +00:00
Peter Steinberger
17a65a6f4c test: split pure docker exec arg checks from bash e2e suite 2026-02-22 10:44:40 +00:00
Peter Steinberger
239963ac44 perf(test): shrink bash command fixtures and polling windows 2026-02-22 10:43:22 +00:00
Peter Steinberger
1d7dbd8cd9 test: reclassify web fetch/readability suites as unit tests 2026-02-22 10:41:29 +00:00
Peter Steinberger
304eef575b test: reclassify sandbox and web/image tool suites as unit tests 2026-02-22 10:40:40 +00:00
Peter Steinberger
3b09a0d2d0 perf(test): trim bash e2e log fixtures and abort wait bounds 2026-02-22 10:39:18 +00:00
Peter Steinberger
c68bb8d6d5 test: stabilize bash e2e suites with explicit exec approvals mode 2026-02-22 10:37:44 +00:00
Peter Steinberger
38f02c7a32 fix(session): resolve agent session path with configured sessions dir
Co-authored-by: David Rudduck <david@rudduck.org.au>
2026-02-22 11:35:55 +01:00
Peter Steinberger
c283f87ab0 refactor: clarify strict loopback proxy audit rules 2026-02-22 11:35:08 +01:00
Peter Steinberger
97eb4af01e test: harden models-config env isolation list 2026-02-22 10:34:23 +00:00
Peter Steinberger
744df0fbe7 test: reclassify models-config suites from e2e to unit lane 2026-02-22 10:34:23 +00:00
Peter Steinberger
740fd7ae35 test: reclassify skills suites from e2e to unit lane 2026-02-22 10:34:23 +00:00
Peter Steinberger
5c57a45a59 fix: add non-streaming directive-tag regression tests (#23298) (thanks @SidQin-cyber) 2026-02-22 11:31:23 +01:00
SidQin-cyber
e6490732cd fix(gateway): strip directive tags from non-streaming webchat broadcasts
Closes #23053

The streaming path already strips [[reply_to_current]] and other
directive tags via stripInlineDirectiveTagsForDisplay, but the
non-streaming broadcastChatFinal path and the chat.inject path
sent raw message content to webchat clients, causing tags to
appear in rendered messages after streaming completes.
2026-02-22 11:31:23 +01:00
Peter Steinberger
c56ab39da5 perf(test): reduce bash e2e wait windows 2026-02-22 10:28:43 +00:00
Peter Steinberger
abff3f0f61 test: reclassify sessions_spawn lifecycle suite as unit test 2026-02-22 10:28:43 +00:00
Peter Steinberger
0b7c7ee1aa perf(test): speed up sessions_spawn lifecycle suite setup 2026-02-22 10:28:43 +00:00
Peter Steinberger
c962bcba37 test: reclassify sandbox merge and exec path suites as unit tests 2026-02-22 10:28:43 +00:00
Peter Steinberger
9ab7b85a66 perf(test): tighten background abort timing windows 2026-02-22 10:28:43 +00:00
Peter Steinberger
c995f9be07 test: reclassify mocked announce and sandbox suites as unit tests 2026-02-22 10:28:43 +00:00
Peter Steinberger
27f0d7ebcc test: reclassify auth-profile-rotation suite as unit test 2026-02-22 10:28:43 +00:00
Peter Steinberger
c0b1c10a08 test: reclassify mocked runner/safe-bins suites as unit tests 2026-02-22 10:28:43 +00:00
Peter Steinberger
a9b26d83de perf(test): narrow pi-embedded runner e2e import path 2026-02-22 10:28:42 +00:00
Peter Steinberger
2b0ca9447c perf(test): trim bash e2e sleep and poll windows 2026-02-22 10:28:42 +00:00
Peter Steinberger
c348a13640 perf(test): lower subagent fast-mode wait floors 2026-02-22 10:28:42 +00:00
Peter Steinberger
54e0786ba6 perf(test): reduce subagent announce fast-mode polling waits 2026-02-22 10:28:42 +00:00
Peter Steinberger
a96139e18c perf(test): mock compact module in auth rotation e2e 2026-02-22 10:28:42 +00:00
Peter Steinberger
eda941f395 perf(test): remove flaky transport timeout and dedupe safeBins checks 2026-02-22 10:28:42 +00:00
Peter Steinberger
d72b4ead18 perf(test): lower fast-mode nested output wait floor to 70ms 2026-02-22 10:28:42 +00:00
Peter Steinberger
7ccf62fb4c test(agents): remove dead shell-timeout override in safeBins suite 2026-02-22 10:28:42 +00:00
Peter Steinberger
60773c124e perf(test): lower fast-mode nested output wait floor to 80ms 2026-02-22 10:28:42 +00:00
Peter Steinberger
36375f121f perf(test): trim nested subagent output wait floor in fast mode 2026-02-22 10:28:42 +00:00
Peter Steinberger
2900eb5456 perf(test): trim background abort settle waits and dedupe cmd fixture 2026-02-22 10:28:42 +00:00
Peter Steinberger
7d13227d41 test(agents): dedupe auth profile rotation fixture setup 2026-02-22 10:28:42 +00:00
Peter Steinberger
6b5c20055b perf(test): speed subagent announce retry polling in fast mode 2026-02-22 10:28:42 +00:00
Peter Steinberger
8ad85de800 test(reply): align native trigger suite with fast-test fixture patterns 2026-02-22 10:28:42 +00:00
Peter Steinberger
9882bfe186 perf(test): compact remaining heartbeat fixture writes 2026-02-22 10:28:42 +00:00
Peter Steinberger
c8d473c8e8 test(heartbeat): use shared sandbox in sender target suite 2026-02-22 10:28:42 +00:00
Peter Steinberger
29e41d4c0a fix: land security audit severity + temp-path guard fixes (#23428) (thanks @bmendonca3) 2026-02-22 11:26:17 +01:00
Brian Mendonca
bc78b343ba Security: expand audit checks for mDNS and real-IP fallback 2026-02-22 11:26:17 +01:00
Peter Steinberger
b13fc7eccd docs(security): clarify workspace memory trust boundary 2026-02-22 11:22:29 +01:00
Peter Steinberger
1cd3b30907 fix: stop hardcoded channel fallback and auto-pick sole configured channel (#23357) (thanks @lbo728)
Co-authored-by: lbo728 <extreme0728@gmail.com>
2026-02-22 11:21:43 +01:00
Frank Yang
e33d7fcd13 fix(telegram): prevent update offset skipping queued updates (#23284)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 92efaf956b
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-22 15:50:33 +05:30
maweibin
98a03c490b Feat/logger support log level validation0222 (#23436)
* 1、环境变量**:新增 `OPENCLAW_LOG_LEVEL`,可取值 `silent|fatal|error|warn|info|debug|trace`。设置后同时覆盖**文件日志**与**控制台**的级别,优先级高于配置文件。
2、启动参数**:在 `openclaw gateway run` 上新增 `--log-level <level>`,对该次进程同时生效于文件与控制台;未传时仍使用环境变量或配置文件。

* fix(logging): make log-level override global and precedence-safe

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-22 11:15:13 +01:00
Peter Steinberger
bf56196de3 fix: tighten feishu dedupe boundary (#23377) (thanks @SidQin-cyber) 2026-02-22 11:13:40 +01:00
SidQin-cyber
9e5e555ba3 fix(feishu): address dedup race condition, namespace isolation, and cache staleness
- Prefix memoryCache keys with namespace to prevent cross-account false
  positives when different accounts receive the same message_id
- Add inflight tracking map to prevent TOCTOU race where concurrent
  async calls for the same message both pass the check and both proceed
- Remove expired-entry deletion from has() to avoid silent cache/disk
  divergence; actual cleanup happens probabilistically inside record()
- Add time-based cache invalidation (30s) to DedupStore.load() so
  external writes are eventually picked up
- Refresh cacheLoadedAt after flush() so we don't immediately re-read
  data we just wrote

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-22 11:13:40 +01:00
SidQin-cyber
9a8179fd59 feat(feishu): persistent message deduplication to prevent duplicate replies
Closes #23369

Feishu may redeliver the same message during WebSocket reconnects or process
restarts.  The existing in-memory dedup map is lost on restart, so duplicates
slip through.

This adds a dual-layer dedup strategy:
- Memory cache (fast synchronous path, unchanged capacity)
- Filesystem store (~/.openclaw/feishu/dedup/) that survives restarts

TTL is extended from 30 min to 24 h.  Disk writes use atomic rename and
probabilistic cleanup to keep each per-account file under 10 k entries.
Disk errors are caught and logged — message handling falls back to
memory-only behaviour so it is never blocked.
2026-02-22 11:13:40 +01:00
Peter Steinberger
73804abcec fix(feishu): avoid template tmpdir join in dedup state path (#23398) 2026-02-22 11:12:01 +01:00
Peter Steinberger
bfc9ecf32e test: harden temp path guard detection (#23398) 2026-02-22 11:12:01 +01:00
Peter Steinberger
57ce7214d2 test: stabilize temp-path guard across runtimes (#23398) 2026-02-22 11:12:01 +01:00
Peter Steinberger
1b327da6e3 fix: harden exec sandbox fallback semantics (#23398) (thanks @bmendonca3) 2026-02-22 11:12:01 +01:00
Brian Mendonca
c76a47cce2 Exec: fail closed when sandbox host is unavailable 2026-02-22 11:12:01 +01:00
Peter Steinberger
5a0032de3e refactor(signal): extract daemon lifecycle and typed exit handling 2026-02-22 11:09:10 +01:00
3627 changed files with 287788 additions and 82281 deletions

2
.gitattributes vendored
View File

@@ -1 +1,3 @@
* text=auto eol=lf
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text

View File

@@ -1,5 +1,5 @@
name: Bug report
description: Report a defect or unexpected behavior in OpenClaw.
description: Report defects, including regressions, crashes, and behavior bugs.
title: "[Bug]: "
labels:
- bug
@@ -8,6 +8,17 @@ body:
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
- type: dropdown
id: bug_type
attributes:
label: Bug type
description: Choose the category that best matches this report.
options:
- Regression (worked before, now fails)
- Crash (process/app exits or hangs)
- Behavior bug (incorrect output/state without crash)
validations:
required: true
- type: textarea
id: summary
attributes:
@@ -91,5 +102,5 @@ body:
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...

View File

@@ -7,6 +7,7 @@ registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
token: ${{secrets.NPM_NPMJS_TOKEN}}
replaces-base: true
updates:
@@ -14,9 +15,9 @@ updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
production:
dependency-type: production
@@ -36,9 +37,9 @@ updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
actions:
patterns:
@@ -52,9 +53,9 @@ updates:
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
swift-deps:
patterns:
@@ -68,9 +69,9 @@ updates:
- package-ecosystem: swift
directory: /apps/shared/MoltbotKit
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
swift-deps:
patterns:
@@ -84,9 +85,9 @@ updates:
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
swift-deps:
patterns:
@@ -100,9 +101,9 @@ updates:
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
interval: daily
cooldown:
default-days: 7
default-days: 2
groups:
android-deps:
patterns:
@@ -118,7 +119,7 @@ updates:
schedule:
interval: weekly
cooldown:
default-days: 7
default-days: 2
groups:
docker-images:
patterns:

4
.github/labeler.yml vendored
View File

@@ -240,6 +240,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: acpx":
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:

View File

@@ -3,6 +3,8 @@ name: Auto response
on:
issues:
types: [opened, edited, labeled]
issue_comment:
types: [created]
pull_request_target:
types: [labeled]
@@ -17,13 +19,20 @@ jobs:
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Handle labeled items
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [
@@ -42,6 +51,7 @@ jobs:
{
label: "r: testflight",
close: true,
commentTriggers: ["testflight"],
message: "Not available, build from source.",
},
{
@@ -55,11 +65,186 @@ jobs:
close: true,
lock: true,
lockReason: "off-topic",
commentTriggers: ["moltbook"],
message:
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
},
];
const maintainerTeam = "maintainer";
const pingWarningMessage =
"Please dont spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
const mentionRegex = /@([A-Za-z0-9-]+)/g;
const maintainerCache = new Map();
const normalizeLogin = (login) => login.toLowerCase();
const bugSubtypeLabelSpecs = {
regression: {
color: "D93F0B",
description: "Behavior that previously worked and now fails",
},
"bug:crash": {
color: "B60205",
description: "Process/app exits unexpectedly or hangs",
},
"bug:behavior": {
color: "D73A4A",
description: "Incorrect behavior without a crash",
},
};
const bugTypeToLabel = {
"Regression (worked before, now fails)": "regression",
"Crash (process/app exits or hangs)": "bug:crash",
"Behavior bug (incorrect output/state without crash)": "bug:behavior",
};
const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);
const extractIssueFormValue = (body, field) => {
if (!body) {
return "";
}
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
"i",
);
const match = body.match(regex);
if (!match) {
return "";
}
for (const line of match[1].split("\n")) {
const trimmed = line.trim();
if (trimmed) {
return trimmed;
}
}
return "";
};
const ensureLabelExists = async (name, color, description) => {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description,
});
}
};
const syncBugSubtypeLabel = async (issue, labelSet) => {
if (!labelSet.has("bug")) {
return;
}
const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
const targetLabel = bugTypeToLabel[selectedBugType];
if (!targetLabel) {
return;
}
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
for (const subtypeLabel of bugSubtypeLabels) {
if (subtypeLabel === targetLabel) {
continue;
}
if (!labelSet.has(subtypeLabel)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: subtypeLabel,
});
labelSet.delete(subtypeLabel);
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
if (!labelSet.has(targetLabel)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [targetLabel],
});
labelSet.add(targetLabel);
}
};
const isMaintainer = async (login) => {
if (!login) {
return false;
}
const normalized = normalizeLogin(login);
if (maintainerCache.has(normalized)) {
return maintainerCache.get(normalized);
}
let isMember = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: maintainerTeam,
username: normalized,
});
isMember = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
maintainerCache.set(normalized, isMember);
return isMember;
};
const countMaintainerMentions = async (body, authorLogin) => {
if (!body) {
return 0;
}
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
return 0;
}
const haystack = body.toLowerCase();
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
if (haystack.includes(teamMention)) {
return 3;
}
const mentions = new Set();
for (const match of body.matchAll(mentionRegex)) {
mentions.add(normalizeLogin(match[1]));
}
if (normalizedAuthor) {
mentions.delete(normalizedAuthor);
}
let count = 0;
for (const login of mentions) {
if (await isMaintainer(login)) {
count += 1;
}
}
return count;
};
const triggerLabel = "trigger-response";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
@@ -72,6 +257,65 @@ jobs:
.filter((name) => typeof name === "string"),
);
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const comment = context.payload.comment;
if (comment) {
const authorLogin = comment.user?.login ?? "";
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
return;
}
const commentBody = comment.body ?? "";
const responses = [];
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
if (mentionCount >= 3) {
responses.push(pingWarningMessage);
}
const commentHaystack = commentBody.toLowerCase();
const commentRule = rules.find((item) =>
(item.commentTriggers ?? []).some((trigger) =>
commentHaystack.includes(trigger),
),
);
if (commentRule) {
responses.push(commentRule.message);
}
if (responses.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
body: responses.join("\n\n"),
});
}
return;
}
if (issue) {
const action = context.payload.action;
if (action === "opened" || action === "edited") {
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
const authorLogin = issue.user?.login ?? "";
const mentionCount = await countMaintainerMentions(
issueText,
authorLogin,
);
if (mentionCount >= 3) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: pingWarningMessage,
});
}
await syncBugSubtypeLabel(issue, labelSet);
}
}
const hasTriggerLabel = labelSet.has(triggerLabel);
if (hasTriggerLabel) {
labelSet.delete(triggerLabel);
@@ -94,7 +338,6 @@ jobs:
return;
}
const issue = context.payload.issue;
if (issue) {
const title = issue.title ?? "";
const body = issue.body ?? "";
@@ -136,7 +379,6 @@ jobs:
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
const pullRequest = context.payload.pull_request;
if (pullRequest) {
if (labelSet.has(dirtyLabel)) {
await github.rest.issues.createComment({

View File

@@ -208,10 +208,6 @@ jobs:
with:
install-bun: "${{ matrix.runtime == 'bun' }}"
- name: Configure vitest JSON reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Configure Node test resources
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: |
@@ -224,21 +220,6 @@ jobs:
if: matrix.runtime != 'bun' || github.event_name != 'push'
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Types, lint, and format check.
check:
name: "check"
@@ -259,6 +240,12 @@ jobs:
- name: Check types and lint and oxfmt
run: pnpm check
- name: Strict TS build smoke
run: pnpm build:strict-smoke
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
# Report-only dead-code scans. Runs after scope detection and stores machine-readable
# results as artifacts for later triage before we enable hard gates.
# Temporarily disabled in CI while we process initial findings.
@@ -317,7 +304,9 @@ jobs:
- name: Check docs
run: pnpm check:docs
secrets:
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -330,10 +319,39 @@ jobs:
with:
python-version: "3.12"
- name: Install detect-secrets
- name: Install Python tooling
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
python -m pip install pytest ruff pyyaml
- name: Lint Python skill scripts
run: python -m ruff check skills
- name: Test skill Python scripts
run: python -m pytest -q skills
secrets:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pre-commit
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit detect-secrets==1.5.0
- name: Detect secrets
run: |
@@ -342,10 +360,35 @@ jobs:
exit 1
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
if [ "${#workflow_files[@]}" -eq 0 ]; then
echo "No workflow changes detected; skipping zizmor."
exit 0
fi
pre-commit run zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
run: pre-commit run --all-files pnpm-audit-prod
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-windows-2025
timeout-minutes: 45
env:
NODE_OPTIONS: --max-old-space-size=4096
# Keep total concurrency predictable on the 16 vCPU runner:
@@ -360,12 +403,23 @@ jobs:
include:
- runtime: node
task: lint
shard_index: 0
shard_count: 1
command: pnpm lint
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: protocol
shard_index: 0
shard_count: 1
command: pnpm protocol:check
steps:
- name: Checkout
@@ -437,28 +491,15 @@ jobs:
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Configure vitest JSON reports
- name: Configure test shard (Windows)
if: matrix.task == 'test'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
run: |
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: matrix.task == 'test'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: matrix.task == 'test'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job

View File

@@ -69,6 +69,27 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve OCI labels (amd64)
id: labels
shell: bash
run: |
set -euo pipefail
version="${GITHUB_SHA}"
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
version="main"
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
fi
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
echo "value<<EOF"
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
echo "org.opencontainers.image.version=${version}"
echo "org.opencontainers.image.created=${created}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
@@ -76,6 +97,7 @@ jobs:
context: .
platforms: linux/amd64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
provenance: false
@@ -128,6 +150,27 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve OCI labels (arm64)
id: labels
shell: bash
run: |
set -euo pipefail
version="${GITHUB_SHA}"
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
version="main"
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
fi
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
echo "value<<EOF"
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
echo "org.opencontainers.image.version=${version}"
echo "org.opencontainers.image.created=${created}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
@@ -135,6 +178,7 @@ jobs:
context: .
platforms: linux/arm64
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
provenance: false
@@ -172,6 +216,9 @@ jobs:
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
tags+=("${IMAGE}:latest")
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"

View File

@@ -48,6 +48,11 @@ jobs:
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Run root Dockerfile CLI smoke
run: |
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run installer docker tests
env:
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh

View File

@@ -27,18 +27,25 @@ jobs:
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
sync-labels: true
- name: Apply PR size label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
@@ -127,7 +134,7 @@ jobs:
- name: Apply maintainer or trusted-contributor label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
const login = context.payload.pull_request?.user?.login;
if (!login) {
@@ -204,13 +211,20 @@ jobs:
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Backfill PR labels
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
@@ -444,13 +458,20 @@ jobs:
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Apply maintainer or trusted-contributor label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
script: |
const login = context.payload.issue?.user?.login;
if (!login) {

View File

@@ -1,51 +0,0 @@
name: Stale
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions: {}
jobs:
stale:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Mark stale issues and pull requests
uses: actions/stale@v9
with:
repo-token: ${{ steps.app-token.outputs.token }}
days-before-issue-stale: 7
days-before-issue-close: 5
days-before-pr-stale: 5
days-before-pr-close: 3
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
operations-per-run: 10000
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |
This issue has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
stale-pr-message: |
This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

19
.gitignore vendored
View File

@@ -17,6 +17,8 @@ __pycache__/
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
packages/dashboard-next/.next/
packages/dashboard-next/out/
# Mise configuration files
mise.toml
@@ -96,8 +98,25 @@ package-lock.json
.agents/
.agents
.agent/
skills-lock.json
# Local iOS signing overrides
apps/ios/LocalSigning.xcconfig
# Xcode build directories (xcodebuild output)
apps/ios/build/
apps/shared/OpenClawKit/build/
Swabble/build/
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json
.ant-colony/
# Eclipse
**/.project
**/.classpath
**/.settings/
**/.gradle/
# Synthing
**/.stfolder/

13
.mailmap Normal file
View File

@@ -0,0 +1,13 @@
# Canonical contributor identity mappings for cherry-picked commits.
bmendonca3 <208517100+bmendonca3@users.noreply.github.com> <brianmendonca@Brians-MacBook-Air.local>
hcl <7755017+hclsys@users.noreply.github.com> <chenglunhu@gmail.com>
Glucksberg <80581902+Glucksberg@users.noreply.github.com> <markuscontasul@gmail.com>
JackyWay <53031570+JackyWay@users.noreply.github.com> <jackybbc@gmail.com>
Marcus Castro <7562095+mcaxtr@users.noreply.github.com> <mcaxtr@gmail.com>
Marc Gratch <2238658+mgratch@users.noreply.github.com> <me@marcgratch.com>
Peter Machona <7957943+chilu18@users.noreply.github.com> <chilu.machona@icloud.com>
Ben Marvell <92585+easternbloc@users.noreply.github.com> <ben@marvell.consulting>
zerone0x <39543393+zerone0x@users.noreply.github.com> <hi@trine.dev>
Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> <m.didionisio23@gmail.com>
mujiannan <46643837+mujiannan@users.noreply.github.com> <shennan@mujiannan.com>
Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> <noreply@anthropic.com>

2
.npmrc
View File

@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.

View File

@@ -11,12 +11,14 @@
"ignorePatterns": [
"apps/",
"assets/",
"CLAUDE.md",
"docker-compose.yml",
"dist/",
"docs/_layouts/",
"node_modules/",
"patches/",
"pnpm-lock.yaml/",
"src/gateway/server-methods/CLAUDE.md",
"src/auto-reply/reply/export-html/",
"Swabble/",
"vendor/",

View File

@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
import { showPagedSelectList } from "./ui/paged-select";
interface FileInfo {
status: string;
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
}
};
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
// Build select items with colored status
const items: SelectItem[] = files.map((f) => {
let statusColor: string;
switch (f.status) {
case "M":
statusColor = theme.fg("warning", f.status);
break;
case "A":
statusColor = theme.fg("success", f.status);
break;
case "D":
statusColor = theme.fg("error", f.status);
break;
case "?":
statusColor = theme.fg("muted", f.status);
break;
default:
statusColor = theme.fg("dim", f.status);
}
return {
value: f,
label: `${statusColor} ${f.file}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
const items = files.map((file) => ({
value: file,
label: `${file.status} ${file.file}`,
}));
await showPagedSelectList({
ctx,
title: " Select file to diff",
items,
onSelect: (item) => {
void openSelected(item.value as FileInfo);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
},
});
},
});

View File

@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
import { showPagedSelectList } from "./ui/paged-select";
interface FileEntry {
path: string;
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
}
};
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
// Build select items with colored operations
const items: SelectItem[] = files.map((f) => {
const ops: string[] = [];
if (f.operations.has("read")) {
ops.push(theme.fg("muted", "R"));
}
if (f.operations.has("write")) {
ops.push(theme.fg("success", "W"));
}
if (f.operations.has("edit")) {
ops.push(theme.fg("warning", "E"));
}
const opsLabel = ops.join("");
return {
value: f,
label: `${opsLabel} ${f.path}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileEntry);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
const items = files.map((file) => {
const ops: string[] = [];
if (file.operations.has("read")) {
ops.push("R");
}
if (file.operations.has("write")) {
ops.push("W");
}
if (file.operations.has("edit")) {
ops.push("E");
}
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
value: file,
label: `${ops.join("")} ${file.path}`,
};
});
await showPagedSelectList({
ctx,
title: " Select file to open",
items,
onSelect: (item) => {
void openSelected(item.value as FileEntry);
},
});
},
});
}

View File

@@ -0,0 +1,82 @@
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
type CustomUiContext = {
ui: {
custom: <T>(
render: (
tui: { requestRender: () => void },
theme: {
fg: (tone: string, text: string) => string;
bold: (text: string) => string;
},
kb: unknown,
done: () => void,
) => {
render: (width: number) => string;
invalidate: () => void;
handleInput: (data: string) => void;
},
) => Promise<T>;
};
};
export async function showPagedSelectList(params: {
ctx: CustomUiContext;
title: string;
items: SelectItem[];
onSelect: (item: SelectItem) => void;
}): Promise<void> {
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
const visibleRows = Math.min(params.items.length, 15);
let currentIndex = 0;
const selectList = new SelectList(params.items, visibleRows, {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => text,
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});
selectList.onSelect = (item) => params.onSelect(item);
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = params.items.indexOf(item);
};
container.addChild(selectList);
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (width) => container.render(width),
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.left)) {
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
}

View File

@@ -18,6 +18,8 @@ repos:
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- id: detect-private-key
exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)'
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
@@ -45,7 +47,6 @@ repos:
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
@@ -69,9 +70,34 @@ repos:
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: "^(vendor/|Swabble/)"
# Python checks for skills scripts
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.1
hooks:
- id: ruff
files: "^skills/.*\\.py$"
args: [--config, pyproject.toml]
- repo: local
hooks:
- id: skills-python-tests
name: skills python tests
entry: pytest -q skills
language: python
additional_dependencies: [pytest>=8, <9]
pass_filenames: false
files: "^skills/.*\\.py$"
# Project checks (same commands as CI)
- repo: local
hooks:
# pnpm audit --prod --audit-level=high
- id: pnpm-audit-prod
name: pnpm-audit-prod
entry: pnpm audit --prod --audit-level=high
language: system
pass_filenames: false
# oxlint --type-aware src test
- id: oxlint
name: oxlint

View File

@@ -1,7 +1,11 @@
# 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 `~/...`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
- GitHub linking footgun: dont wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
## Project Structure & Module Organization
@@ -83,6 +87,7 @@
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
@@ -91,6 +96,7 @@
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
@@ -116,6 +122,15 @@
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
## GitHub Search (`gh`)
- Prefer targeted keyword search before proposing new work or duplicating fixes.
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Structured output example:
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
## Security & Configuration Tips
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
@@ -193,6 +208,7 @@
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## NPM + 1Password (publish/verify)

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
AGENTS.md
AGENTS.md

View File

@@ -32,9 +32,15 @@ Welcome to the lobster tank! 🦞
- **Mariano Belinky** - iOS app, Security
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
- **Nimrod Gutman** - iOS app, macOS app and crustacean features
- GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti)
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
- **Val Alexander** - UI/UX, Docs, and Agent DevX
- GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev)
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
@@ -47,11 +53,19 @@ Welcome to the lobster tank! 🦞
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord #setup-help
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR

View File

@@ -1,5 +1,18 @@
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
# OCI base-image metadata for downstream image consumers.
# If you change these annotations, also update:
# - docs/install/docker.md ("Base image metadata" section)
# - https://docs.openclaw.ai/install/docker
LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \
org.opencontainers.image.base.digest="sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935" \
org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
org.opencontainers.image.url="https://openclaw.ai" \
org.opencontainers.image.documentation="https://docs.openclaw.ai/install/docker" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.title="OpenClaw" \
org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image"
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
@@ -23,7 +36,9 @@ COPY --chown=node:node patches ./patches
COPY --chown=node:node scripts ./scripts
USER node
RUN pnpm install --frozen-lockfile
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
# Optionally install Chromium and Xvfb for browser automation.
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
@@ -42,13 +57,58 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
# Optionally install Docker CLI for sandbox container management.
# Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ...
# Adds ~50MB. Only the CLI is installed — no Docker daemon.
# Required for agents.defaults.sandbox to function in Docker deployments.
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gnupg && \
install -m 0755 -d /etc/apt/keyrings && \
# Verify Docker apt signing key fingerprint before trusting it as a root key.
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
exit 1; \
fi && \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg.asc && \
rm -f /tmp/docker.gpg.asc && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\n' \
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
docker-ce-cli docker-compose-plugin && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
USER node
COPY --chown=node:node . .
# Normalize copied plugin/agent paths so plugin safety checks do not reject
# world-writable directories inherited from source file modes.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
# Expose the CLI binary without requiring npm global writes as non-root.
USER root
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs
ENV NODE_ENV=production
# Security hardening: Run as non-root user
@@ -59,7 +119,15 @@ USER node
# Start gateway server with default config.
# Binds to loopback (127.0.0.1) by default for security.
#
# For container platforms requiring external health checks:
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
# IMPORTANT: With Docker bridge networking (-p 18789:18789), loopback bind
# makes the gateway unreachable from the host. Either:
# - Use --network host, OR
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
#
# Built-in probe endpoints for container health checks:
# - GET /healthz (liveness) and GET /readyz (readiness)
# - aliases: /health and /ready
# For external access from host/ingress, override bind to "lan" and set auth.
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

167
README.md
View File

@@ -19,11 +19,11 @@
</p>
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
@@ -32,13 +32,12 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
## Sponsors
| OpenAI | Blacksmith |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) |
| OpenAI | Vercel | Blacksmith | Convex |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Vercel](docs/assets/sponsors/vercel.svg)](https://vercel.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | [![Convex](docs/assets/sponsors/convex.svg)](https://www.convex.dev/) |
**Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for longcontext strength and better promptinjection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
@@ -75,7 +74,7 @@ openclaw gateway --port 18789 --verbose
# Send a message
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WebChat)
openclaw agent --message "Ship checklist" --thinking high
```
@@ -127,9 +126,9 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
@@ -146,19 +145,19 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
### Channels
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
### Apps + nodes
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour + device pairing.
- [Android node](https://docs.openclaw.ai/platforms/android): Connect tab (setup code/manual), chat sessions, voice tab, [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), camera/screen recording, and Android device commands (notifications/location/SMS/photos/contacts/calendar/motion/app update).
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation
@@ -171,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
### Runtime + safety
- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
@@ -186,7 +185,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WebChat
┌───────────────────────────────┐
@@ -208,7 +207,7 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclawmanaged Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agentdriven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — alwayson speech and continuous conversation.
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS plus continuous voice on Android.
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOSonly `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard)
@@ -298,7 +297,7 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
### iOS node (optional)
- Pairs as a node via the Bridge.
- Pairs as a node over the Gateway WebSocket (device pairing).
- Voice trigger forwarding + Canvas surface.
- Controlled via `openclaw nodes …`.
@@ -306,8 +305,8 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Pairs as a WS node via device pairing (`openclaw devices ...`).
- Exposes Connect/Chat/Voice tabs plus Canvas, Camera, Screen capture, and Android device command families.
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
## Agent workspace + skills
@@ -503,78 +502,58 @@ Special thanks to Adam Doppelt for lobster.bot.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a>
<a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a>
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a>
<a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a>
<a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="buerbaumer" title="buerbaumer"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a>
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
<a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="divanoli" title="divanoli"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a>
<a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/CharlieGreenman"><img src="https://avatars.githubusercontent.com/u/8540141?v=4&s=48" width="48" height="48" alt="CharlieGreenman" title="CharlieGreenman"/></a>
<a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="jessy2027" title="jessy2027"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a>
<a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="jscaldwell55" title="jscaldwell55"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a>
<a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="0xRaini" title="0xRaini"/></a> <a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a>
<a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a>
<a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/Jackten"><img src="https://avatars.githubusercontent.com/u/2895479?v=4&s=48" width="48" height="48" alt="Jackten" title="Jackten"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
<a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="simonemacario" title="simonemacario"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a>
<a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="不做了睡大觉" title="不做了睡大觉"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/search?q=nicolasstanley"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a>
<a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/search?q=hudson-rivera"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hudson-rivera" title="hudson-rivera"/></a> <a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a>
<a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a>
<a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="garnetlyx" title="garnetlyx"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="liebertar" title="liebertar"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="Mitsuyuki Osabe" title="Mitsuyuki Osabe"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a>
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="Thorfinn" title="Thorfinn"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a> <a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a> <a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a>
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="dinakars777" title="dinakars777"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="divisonofficer" title="divisonofficer"/></a> <a href="https://github.com/Flash-LHR"><img src="https://avatars.githubusercontent.com/u/47357603?v=4&s=48" width="48" height="48" alt="Flash-LHR" title="Flash-LHR"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
<a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/Iron9521"><img src="https://avatars.githubusercontent.com/u/261863182?v=4&s=48" width="48" height="48" alt="Iron9521" title="Iron9521"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/jabezborja"><img src="https://avatars.githubusercontent.com/u/64759159?v=4&s=48" width="48" height="48" alt="jabezborja" title="jabezborja"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a>
<a href="https://github.com/search?q=%E5%BA%B7%E7%86%99"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="康熙" title="康熙"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="17jmumford" title="17jmumford"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/search?q=Operative-001"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Operative-001" title="Operative-001"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/DylanWoodAkers"><img src="https://avatars.githubusercontent.com/u/253595314?v=4&s=48" width="48" height="48" alt="DylanWoodAkers" title="DylanWoodAkers"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a>
<a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/search?q=zisisp"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zisisp" title="zisisp"/></a>
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
<a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/search?q=Rain"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rain" title="Rain"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/search?q=Ralph"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ralph" title="Ralph"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a> <a href="https://github.com/justinhuangcode"><img src="https://avatars.githubusercontent.com/u/252443740?v=4&s=48" width="48" height="48" alt="justinhuangcode" title="justinhuangcode"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/search?q=ludd50155"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ludd50155" title="ludd50155"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="Mark Liu" title="Mark Liu"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a>
<a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/search?q=Sean%20McLellan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sean McLellan" title="Sean McLellan"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a>
<a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/search?q=Aditya%20Singh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aditya Singh" title="Aditya Singh"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/search?q=Clawdbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot" title="Clawdbot"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a>
<a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hanxiao"><img src="https://avatars.githubusercontent.com/u/2041322?v=4&s=48" width="48" height="48" alt="hanxiao" title="hanxiao"/></a> <a href="https://github.com/search?q=Ignacio"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ignacio" title="Ignacio"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/search?q=Sriram%20Naidu%20Thota"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sriram Naidu Thota" title="Sriram Naidu Thota"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
<a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/search?q=Yao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yao" title="Yao"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=%E5%B0%B9%E5%87%AF"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="尹凯" title="尹凯"/></a> <a href="https://github.com/search?q=%7BSuksham-sharma%7D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="{Suksham-sharma}" title="{Suksham-sharma}"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
<a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=abhijeet117"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a>
<a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/search?q=amabito"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="amabito" title="amabito"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anisoptera"><img src="https://avatars.githubusercontent.com/u/768771?v=4&s=48" width="48" height="48" alt="anisoptera" title="anisoptera"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a>
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/search?q=baccula"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="baccula" title="baccula"/></a> <a href="https://github.com/beefiker"><img src="https://avatars.githubusercontent.com/u/55247450?v=4&s=48" width="48" height="48" alt="beefiker" title="beefiker"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/search?q=blacksmith-sh%5Bbot%5D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/search?q=bqcfjwhz85-arch"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/search?q=bravostation"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Buddy%20(AI)"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Buddy (AI)" title="Buddy (AI)"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a>
<a href="https://github.com/search?q=calvin-hpnet"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="calvin-hpnet" title="calvin-hpnet"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/search?q=chenglun.hu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="chenglun.hu" title="chenglun.hu"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Claw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Claw" title="Claw"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/search?q=cristip73"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/search?q=danielcadenhead"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="danielcadenhead" title="danielcadenhead"/></a> <a href="https://github.com/dario-github"><img src="https://avatars.githubusercontent.com/u/40749119?v=4&s=48" width="48" height="48" alt="dario-github" title="dario-github"/></a> <a href="https://github.com/DarwinsBuddy"><img src="https://avatars.githubusercontent.com/u/490836?v=4&s=48" width="48" height="48" alt="DarwinsBuddy" title="DarwinsBuddy"/></a>
<a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=davidbors-snyk"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="davidbors-snyk" title="davidbors-snyk"/></a> <a href="https://github.com/dcantu96"><img src="https://avatars.githubusercontent.com/u/32658690?v=4&s=48" width="48" height="48" alt="dcantu96" title="dcantu96"/></a> <a href="https://github.com/search?q=dependabot%5Bbot%5D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a>
<a href="https://github.com/search?q=elliotsecops"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/EmberCF"><img src="https://avatars.githubusercontent.com/u/258471336?v=4&s=48" width="48" height="48" alt="EmberCF" title="EmberCF"/></a> <a href="https://github.com/ereid7"><img src="https://avatars.githubusercontent.com/u/27597719?v=4&s=48" width="48" height="48" alt="ereid7" title="ereid7"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/search?q=f-trycua"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/search?q=fan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="fan" title="fan"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/search?q=fujiwara-tofu-shop"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a>
<a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/search?q=gaowanqi08141999"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gaowanqi08141999" title="gaowanqi08141999"/></a> <a href="https://github.com/search?q=gerardward2007"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/search?q=gitpds"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gitpds" title="gitpds"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/search?q=habakan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="habakan" title="habakan"/></a> <a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/search?q=hcl"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/search?q=headswim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="headswim" title="headswim"/></a>
<a href="https://github.com/search?q=hlbbbbbbb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hlbbbbbbb" title="hlbbbbbbb"/></a> <a href="https://github.com/search?q=Hubert"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Hubert" title="Hubert"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=hyaxia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hyaxia" title="hyaxia"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/search?q=ikari"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ikari" title="ikari"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/search?q=Iron"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Iron" title="Iron"/></a> <a href="https://github.com/search?q=ironbyte-rgb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/search?q=%C3%8Dtalo%20Souza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ítalo Souza" title="Ítalo Souza"/></a>
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=jarvis89757"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jarvis89757" title="jarvis89757"/></a> <a href="https://github.com/search?q=jasonftl"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jasonftl" title="jasonftl"/></a> <a href="https://github.com/search?q=jasonsschin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=jg-noncelogic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jg-noncelogic" title="jg-noncelogic"/></a> <a href="https://github.com/search?q=jigar"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jigar" title="jigar"/></a> <a href="https://github.com/search?q=joeynyc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="joeynyc" title="joeynyc"/></a>
<a href="https://github.com/search?q=Jon%20Uleis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jon Uleis" title="Jon Uleis"/></a> <a href="https://github.com/search?q=Josh%20Long"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Josh Long" title="Josh Long"/></a> <a href="https://github.com/search?q=justyannicc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="justyannicc" title="justyannicc"/></a> <a href="https://github.com/search?q=Karim%20Naguib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Karim Naguib" title="Karim Naguib"/></a> <a href="https://github.com/search?q=Kasper%20Neist%20Christjansen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kasper Neist Christjansen" title="Kasper Neist Christjansen"/></a> <a href="https://github.com/search?q=Keshav%20Rao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keshav Rao" title="Keshav Rao"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/search?q=Kira"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kira" title="Kira"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/search?q=Knox"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Knox" title="Knox"/></a>
<a href="https://github.com/search?q=Kristijan%20Jovanovski"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kristijan Jovanovski" title="Kristijan Jovanovski"/></a> <a href="https://github.com/search?q=Kyle%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kyle Chen" title="Kyle Chen"/></a> <a href="https://github.com/search?q=Latitude%20Bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Latitude Bot" title="Latitude Bot"/></a> <a href="https://github.com/search?q=Levi%20Figueira"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Levi Figueira" title="Levi Figueira"/></a> <a href="https://github.com/search?q=Liu%20Weizhan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Liu Weizhan" title="Liu Weizhan"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/search?q=Loganaden%20Velvindron"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Loganaden Velvindron" title="Loganaden Velvindron"/></a> <a href="https://github.com/search?q=lsh411"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="lsh411" title="lsh411"/></a> <a href="https://github.com/search?q=Lucas%20Kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lucas Kim" title="Lucas Kim"/></a> <a href="https://github.com/search?q=Luka%20Zhang"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Luka Zhang" title="Luka Zhang"/></a>
<a href="https://github.com/search?q=Luk%C3%A1%C5%A1%20Loukota"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lukáš Loukota" title="Lukáš Loukota"/></a> <a href="https://github.com/search?q=Lukin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lukin" title="Lukin"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/search?q=mac26ai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac26ai" title="mac26ai"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/search?q=Mahsum%20Aktas"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mahsum Aktas" title="Mahsum Aktas"/></a> <a href="https://github.com/search?q=Marc%20Beaupre"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc Beaupre" title="Marc Beaupre"/></a> <a href="https://github.com/search?q=Marcus%20Neves"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marcus Neves" title="Marcus Neves"/></a> <a href="https://github.com/search?q=Mario%20Zechner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mario Zechner" title="Mario Zechner"/></a> <a href="https://github.com/search?q=Markus%20Buhatem%20Koch"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Markus Buhatem Koch" title="Markus Buhatem Koch"/></a>
<a href="https://github.com/search?q=Martin%20P%C3%BA%C4%8Dik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Martin Púčik" title="Martin Púčik"/></a> <a href="https://github.com/search?q=Martin%20Sch%C3%BCrrer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Martin Schürrer" title="Martin Schürrer"/></a> <a href="https://github.com/search?q=MarvinDontPanic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="MarvinDontPanic" title="MarvinDontPanic"/></a> <a href="https://github.com/search?q=Mateusz%20Michalik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mateusz Michalik" title="Mateusz Michalik"/></a> <a href="https://github.com/search?q=Matias%20Wainsten"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matias Wainsten" title="Matias Wainsten"/></a> <a href="https://github.com/search?q=Matt%20Ezell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt Ezell" title="Matt Ezell"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Matthew%20Dicembrino"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matthew Dicembrino" title="Matthew Dicembrino"/></a> <a href="https://github.com/search?q=Mauro%20Bolis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mauro Bolis" title="Mauro Bolis"/></a> <a href="https://github.com/search?q=mcwigglesmcgee"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mcwigglesmcgee" title="mcwigglesmcgee"/></a>
<a href="https://github.com/search?q=meaadore1221-afk"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="meaadore1221-afk" title="meaadore1221-afk"/></a> <a href="https://github.com/search?q=Mert%20%C3%87i%C3%A7ek%C3%A7i"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mert Çiçekçi" title="Mert Çiçekçi"/></a> <a href="https://github.com/search?q=Michael%20Verrilli"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Michael Verrilli" title="Michael Verrilli"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/search?q=minghinmatthewlam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/search?q=Mr.%20Guy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mr. Guy" title="Mr. Guy"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/search?q=myfunc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/search?q=Nate"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nate" title="Nate"/></a>
<a href="https://github.com/search?q=Nathaniel%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nathaniel Kelner" title="Nathaniel Kelner"/></a> <a href="https://github.com/search?q=Netanel%20Draiman"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Netanel Draiman" title="Netanel Draiman"/></a> <a href="https://github.com/search?q=niceysam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/search?q=Nick%20Lamb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nick Lamb" title="Nick Lamb"/></a> <a href="https://github.com/search?q=Nick%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nick Taylor" title="Nick Taylor"/></a> <a href="https://github.com/search?q=Nikolay%20Petrov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nikolay Petrov" title="Nikolay Petrov"/></a> <a href="https://github.com/search?q=NM"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="NM" title="NM"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/search?q=norunners"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="norunners" title="norunners"/></a>
<a href="https://github.com/search?q=Ocean%20Vael"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ocean Vael" title="Ocean Vael"/></a> <a href="https://github.com/search?q=Ogulcan%20Celik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ogulcan Celik" title="Ogulcan Celik"/></a> <a href="https://github.com/search?q=Oleg%20Kossoy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Oleg Kossoy" title="Oleg Kossoy"/></a> <a href="https://github.com/Olshansk"><img src="https://avatars.githubusercontent.com/u/1892194?v=4&s=48" width="48" height="48" alt="Olshansk" title="Olshansk"/></a> <a href="https://github.com/search?q=Omar%20Khaleel"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Omar Khaleel" title="Omar Khaleel"/></a> <a href="https://github.com/search?q=OpenClaw%20Agent"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="OpenClaw Agent" title="OpenClaw Agent"/></a> <a href="https://github.com/search?q=Ozgur%20Polat"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ozgur Polat" title="Ozgur Polat"/></a> <a href="https://github.com/search?q=Pablo%20Nunez"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pablo Nunez" title="Pablo Nunez"/></a> <a href="https://github.com/search?q=Palash%20Oswal"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Palash Oswal" title="Palash Oswal"/></a> <a href="https://github.com/search?q=pasogott"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pasogott" title="pasogott"/></a>
<a href="https://github.com/search?q=Patrick%20Shao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Patrick Shao" title="Patrick Shao"/></a> <a href="https://github.com/search?q=Paul%20Pamment"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Paul Pamment" title="Paul Pamment"/></a> <a href="https://github.com/search?q=Paulo%20Portella"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Paulo Portella" title="Paulo Portella"/></a> <a href="https://github.com/search?q=Peter%20Lee"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Peter Lee" title="Peter Lee"/></a> <a href="https://github.com/search?q=Petra%20Donka"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Petra Donka" title="Petra Donka"/></a> <a href="https://github.com/search?q=Pham%20Nam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pham Nam" title="Pham Nam"/></a> <a href="https://github.com/search?q=pierreeurope"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pierreeurope" title="pierreeurope"/></a> <a href="https://github.com/search?q=pip-nomel"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pip-nomel" title="pip-nomel"/></a> <a href="https://github.com/search?q=plum-dawg"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/search?q=pookNast"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pookNast" title="pookNast"/></a>
<a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=rafaelreis-r"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/search?q=Ramin%20Shirali%20Hossein%20Zade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ramin Shirali Hossein Zade" title="Ramin Shirali Hossein Zade"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Raphael%20Borg%20Ellul%20Vincenti"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Raphael Borg Ellul Vincenti" title="Raphael Borg Ellul Vincenti"/></a> <a href="https://github.com/search?q=Ratul%20Sarna"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ratul Sarna" title="Ratul Sarna"/></a> <a href="https://github.com/search?q=Richard%20Pinedo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Richard Pinedo" title="Richard Pinedo"/></a> <a href="https://github.com/search?q=Rick%20Qian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rick Qian" title="Rick Qian"/></a>
<a href="https://github.com/search?q=robhparker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Rohan%20Nagpal"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rohan Nagpal" title="Rohan Nagpal"/></a> <a href="https://github.com/search?q=Rohan%20Patil"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rohan Patil" title="Rohan Patil"/></a> <a href="https://github.com/rohanpatriot"><img src="https://avatars.githubusercontent.com/u/59978389?v=4&s=48" width="48" height="48" alt="rohanpatriot" title="rohanpatriot"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Ryan%20Nelson"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Nelson" title="Ryan Nelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/search?q=Santosh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Santosh" title="Santosh"/></a> <a href="https://github.com/search?q=Sascha%20Reuter"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sascha Reuter" title="Sascha Reuter"/></a>
<a href="https://github.com/search?q=Saurabh.Chopade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Saurabh.Chopade" title="Saurabh.Chopade"/></a> <a href="https://github.com/search?q=saurav470"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="saurav470" title="saurav470"/></a> <a href="https://github.com/search?q=seans-openclawbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seans-openclawbot" title="seans-openclawbot"/></a> <a href="https://github.com/SecondThread"><img src="https://avatars.githubusercontent.com/u/18317476?v=4&s=48" width="48" height="48" alt="SecondThread" title="SecondThread"/></a> <a href="https://github.com/search?q=seewhy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seewhy" title="seewhy"/></a> <a href="https://github.com/search?q=Senol%20Dogan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Senol Dogan" title="Senol Dogan"/></a> <a href="https://github.com/search?q=Sergiy%20Dybskiy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sergiy Dybskiy" title="Sergiy Dybskiy"/></a> <a href="https://github.com/search?q=Shadow"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shadow" title="Shadow"/></a> <a href="https://github.com/search?q=shatner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/search?q=Shaun%20Loo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shaun Loo" title="Shaun Loo"/></a>
<a href="https://github.com/search?q=Shaun%20Mason"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shaun Mason" title="Shaun Mason"/></a> <a href="https://github.com/search?q=Shiva%20Prasad"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shiva Prasad" title="Shiva Prasad"/></a> <a href="https://github.com/search?q=Shrinija%20Kummari"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shrinija Kummari" title="Shrinija Kummari"/></a> <a href="https://github.com/search?q=Siddhant%20Jain"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Siddhant Jain" title="Siddhant Jain"/></a> <a href="https://github.com/search?q=Simon%20Kelly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Simon Kelly" title="Simon Kelly"/></a> <a href="https://github.com/search?q=SK%20Heavy%20Industries"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="SK Heavy Industries" title="SK Heavy Industries"/></a> <a href="https://github.com/sldkfoiweuaranwdlaiwyeoaw"><img src="https://avatars.githubusercontent.com/u/2593660?v=4&s=48" width="48" height="48" alt="sldkfoiweuaranwdlaiwyeoaw" title="sldkfoiweuaranwdlaiwyeoaw"/></a> <a href="https://github.com/search?q=Soumyadeep%20Ghosh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Soumyadeep Ghosh" title="Soumyadeep Ghosh"/></a> <a href="https://github.com/search?q=Spacefish"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Spacefish" title="Spacefish"/></a> <a href="https://github.com/search?q=spiceoogway"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a>
<a href="https://github.com/search?q=Stephen%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Stephen Chen" title="Stephen Chen"/></a> <a href="https://github.com/search?q=Steve"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Steve" title="Steve"/></a> <a href="https://github.com/search?q=succ985"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="succ985" title="succ985"/></a> <a href="https://github.com/search?q=Suksham"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Suksham" title="Suksham"/></a> <a href="https://github.com/search?q=Sunwoo%20Yu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sunwoo Yu" title="Sunwoo Yu"/></a> <a href="https://github.com/search?q=Suvin%20Nimnaka"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Suvin Nimnaka" title="Suvin Nimnaka"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/swizzmagik"><img src="https://avatars.githubusercontent.com/u/3955528?v=4&s=48" width="48" height="48" alt="swizzmagik" title="swizzmagik"/></a> <a href="https://github.com/search?q=Tag"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tag" title="Tag"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a>
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=tewatia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/search?q=therealZpoint-bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/search?q=tian%20Xiao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tian Xiao" title="tian Xiao"/></a> <a href="https://github.com/search?q=Tim%20Krase"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tim Krase" title="Tim Krase"/></a> <a href="https://github.com/search?q=Timo%20Lins"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Timo Lins" title="Timo Lins"/></a> <a href="https://github.com/search?q=Tom%20McKenzie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tom McKenzie" title="Tom McKenzie"/></a> <a href="https://github.com/search?q=Tom%20Peri"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tom Peri" title="Tom Peri"/></a> <a href="https://github.com/search?q=Tomas%20Hajek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tomas Hajek" title="Tomas Hajek"/></a>
<a href="https://github.com/search?q=Tomsun28"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tomsun28" title="Tomsun28"/></a> <a href="https://github.com/search?q=Tonic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tonic" title="Tonic"/></a> <a href="https://github.com/search?q=Travis%20Hinton"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Travis Hinton" title="Travis Hinton"/></a> <a href="https://github.com/search?q=Travis%20Irby"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Travis Irby" title="Travis Irby"/></a> <a href="https://github.com/search?q=Tulsi%20Prasad"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tulsi Prasad" title="Tulsi Prasad"/></a> <a href="https://github.com/search?q=Ty%20Sabs"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ty Sabs" title="Ty Sabs"/></a> <a href="https://github.com/search?q=Tyler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tyler" title="Tyler"/></a> <a href="https://github.com/search?q=uos-status"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/search?q=Vai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vai" title="Vai"/></a> <a href="https://github.com/search?q=Varun%20Kruthiventi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Varun Kruthiventi" title="Varun Kruthiventi"/></a>
<a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/search?q=Victor%20Castell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Victor Castell" title="Victor Castell"/></a> <a href="https://github.com/search?q=victor-wu.eth"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="victor-wu.eth" title="victor-wu.eth"/></a> <a href="https://github.com/search?q=vikpos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="vikpos" title="vikpos"/></a> <a href="https://github.com/search?q=Vincent"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vincent" title="Vincent"/></a> <a href="https://github.com/search?q=VintLin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VintLin" title="VintLin"/></a> <a href="https://github.com/search?q=Vladimir%20Peshekhonov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vladimir Peshekhonov" title="Vladimir Peshekhonov"/></a> <a href="https://github.com/search?q=void"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="void" title="void"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=williamtwomey"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="williamtwomey" title="williamtwomey"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=Winry"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Winry" title="Winry"/></a> <a href="https://github.com/search?q=Winston"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Winston" title="Winston"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/search?q=Xin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xin" title="Xin"/></a> <a href="https://github.com/search?q=Xinhe%20Hu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xinhe Hu" title="Xinhe Hu"/></a> <a href="https://github.com/search?q=Xu%20Haoran"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xu Haoran" title="Xu Haoran"/></a> <a href="https://github.com/search?q=Yash"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yash" title="Yash"/></a> <a href="https://github.com/search?q=Yaxuan42"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yaxuan42" title="Yaxuan42"/></a>
<a href="https://github.com/search?q=Yazin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yazin" title="Yazin"/></a> <a href="https://github.com/search?q=Yevhen%20Bobrov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yevhen Bobrov" title="Yevhen Bobrov"/></a> <a href="https://github.com/search?q=Yi%20Wang"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yi Wang" title="Yi Wang"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Yuan%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yuan Chen" title="Yuan Chen"/></a> <a href="https://github.com/search?q=Yuanhai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yuanhai" title="Yuanhai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Zaf%20(via%20OpenClaw)"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zaf (via OpenClaw)" title="Zaf (via OpenClaw)"/></a> <a href="https://github.com/search?q=zhixian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zhixian" title="zhixian"/></a> <a href="https://github.com/search?q=%E7%9F%B3%E5%B7%9D%20%E8%AB%92"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="石川 諒" title="石川 諒"/></a>
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/Sid-Qin"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="Sid-Qin" title="Sid-Qin"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/bmendonca3"><img src="https://avatars.githubusercontent.com/u/208517100?v=4&s=48" width="48" height="48" alt="bmendonca3" title="bmendonca3"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="Elonito" title="Elonito"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a>
<a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
<a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a> <a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chilu18"><img src="https://avatars.githubusercontent.com/u/7957943?v=4&s=48" width="48" height="48" alt="chilu18" title="chilu18"/></a> <a href="https://github.com/byungsker"><img src="https://avatars.githubusercontent.com/u/72309817?v=4&s=48" width="48" height="48" alt="byungsker" title="byungsker"/></a>
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
<a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a>
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="stakeswky" title="stakeswky"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></a>
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
<a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/thebenignhacker"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="thebenignhacker" title="thebenignhacker"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/kevinWangSheng"><img src="https://avatars.githubusercontent.com/u/118158941?v=4&s=48" width="48" height="48" alt="kevinWangSheng" title="kevinWangSheng"/></a> <a href="https://github.com/gregmousseau"><img src="https://avatars.githubusercontent.com/u/5036458?v=4&s=48" width="48" height="48" alt="gregmousseau" title="gregmousseau"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/pandego"><img src="https://avatars.githubusercontent.com/u/7780875?v=4&s=48" width="48" height="48" alt="pandego" title="pandego"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/graysurf"><img src="https://avatars.githubusercontent.com/u/10785178?v=4&s=48" width="48" height="48" alt="graysurf" title="graysurf"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a>
<a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
<a href="https://github.com/YuzuruS"><img src="https://avatars.githubusercontent.com/u/1485195?v=4&s=48" width="48" height="48" alt="YuzuruS" title="YuzuruS"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/openperf"><img src="https://avatars.githubusercontent.com/u/80630709?v=4&s=48" width="48" height="48" alt="openperf" title="openperf"/></a> <a href="https://github.com/BUGKillerKing"><img src="https://avatars.githubusercontent.com/u/117326392?v=4&s=48" width="48" height="48" alt="BUGKillerKing" title="BUGKillerKing"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="luijoc" title="luijoc"/></a> <a href="https://github.com/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a>
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/xuhao1"><img src="https://avatars.githubusercontent.com/u/5087930?v=4&s=48" width="48" height="48" alt="XuHao" title="XuHao"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bitfoundry-ai"><img src="https://avatars.githubusercontent.com/u/239082898?v=4&s=48" width="48" height="48" alt="bitfoundry-ai" title="bitfoundry-ai"/></a>
<a href="https://github.com/HeMuling"><img src="https://avatars.githubusercontent.com/u/74801533?v=4&s=48" width="48" height="48" alt="HeMuling" title="HeMuling"/></a> <a href="https://github.com/markmusson"><img src="https://avatars.githubusercontent.com/u/4801649?v=4&s=48" width="48" height="48" alt="markmusson" title="markmusson"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/guirguispierre"><img src="https://avatars.githubusercontent.com/u/22091706?v=4&s=48" width="48" height="48" alt="guirguispierre" title="guirguispierre"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a>
<a href="https://github.com/loganprit"><img src="https://avatars.githubusercontent.com/u/72722788?v=4&s=48" width="48" height="48" alt="loganprit" title="loganprit"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/dbachelder"><img src="https://avatars.githubusercontent.com/u/325706?v=4&s=48" width="48" height="48" alt="dbachelder" title="dbachelder"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="Divanoli Mydeen Pitchai" title="Divanoli Mydeen Pitchai"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a>
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/sahilsatralkar"><img src="https://avatars.githubusercontent.com/u/62758655?v=4&s=48" width="48" height="48" alt="sahilsatralkar" title="sahilsatralkar"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
<a href="https://github.com/scz2011"><img src="https://avatars.githubusercontent.com/u/9337506?v=4&s=48" width="48" height="48" alt="scz2011" title="scz2011"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a>
<a href="https://github.com/mitchmcalister"><img src="https://avatars.githubusercontent.com/u/209334?v=4&s=48" width="48" height="48" alt="mitchmcalister" title="mitchmcalister"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/guxu11"><img src="https://avatars.githubusercontent.com/u/53551744?v=4&s=48" width="48" height="48" alt="Xu Gu" title="Xu Gu"/></a> <a href="https://github.com/lml2468"><img src="https://avatars.githubusercontent.com/u/39320777?v=4&s=48" width="48" height="48" alt="Menglin Li" title="Menglin Li"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/Zitzak"><img src="https://avatars.githubusercontent.com/u/43185740?v=4&s=48" width="48" height="48" alt="Marvin" title="Marvin"/></a>
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/krizpoon"><img src="https://avatars.githubusercontent.com/u/1977532?v=4&s=48" width="48" height="48" alt="krizpoon" title="krizpoon"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16" title="kunalk16"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></a>
<a href="https://github.com/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/BigUncle"><img src="https://avatars.githubusercontent.com/u/9360607?v=4&s=48" width="48" height="48" alt="BigUncle" title="BigUncle"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/zhoulongchao77"><img src="https://avatars.githubusercontent.com/u/65058500?v=4&s=48" width="48" height="48" alt="zhoulc777" title="zhoulc777"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/paceyw"><img src="https://avatars.githubusercontent.com/u/44923937?v=4&s=48" width="48" height="48" alt="TIHU" title="TIHU"/></a> <a href="https://github.com/tonydehnke"><img src="https://avatars.githubusercontent.com/u/36720180?v=4&s=48" width="48" height="48" alt="Tony Dehnke" title="Tony Dehnke"/></a>
<a href="https://github.com/pablohrcarvalho"><img src="https://avatars.githubusercontent.com/u/66948122?v=4&s=48" width="48" height="48" alt="pablohrcarvalho" title="pablohrcarvalho"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/ozbillwang"><img src="https://avatars.githubusercontent.com/u/8954908?v=4&s=48" width="48" height="48" alt="ozbillwang" title="ozbillwang"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/ianderrington"><img src="https://avatars.githubusercontent.com/u/76016868?v=4&s=48" width="48" height="48" alt="Ian Derrington" title="Ian Derrington"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a>
<a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/Youyou972"><img src="https://avatars.githubusercontent.com/u/50808411?v=4&s=48" width="48" height="48" alt="Youyou972" title="Youyou972"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="Dominic" title="Dominic"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a>
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="Dale Babiy" title="Dale Babiy"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/brianleach"><img src="https://avatars.githubusercontent.com/u/1900805?v=4&s=48" width="48" height="48" alt="brianleach" title="brianleach"/></a> <a href="https://github.com/codexGW"><img src="https://avatars.githubusercontent.com/u/9350182?v=4&s=48" width="48" height="48" alt="codexGW" title="codexGW"/></a>
<a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="Chase Dorsey" title="Chase Dorsey"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/tumf"><img src="https://avatars.githubusercontent.com/u/69994?v=4&s=48" width="48" height="48" alt="tumf" title="tumf"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a>
<a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a>
<a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></a>
<a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="Cklee" title="Cklee"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="Garnet Liu" title="Garnet Liu"/></a> <a href="https://github.com/Bermudarat"><img src="https://avatars.githubusercontent.com/u/10937319?v=4&s=48" width="48" height="48" alt="neverland" title="neverland"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/benediktjohannes"><img src="https://avatars.githubusercontent.com/u/253604130?v=4&s=48" width="48" height="48" alt="benediktjohannes" title="benediktjohannes"/></a>
<a href="https://github.com/zhangzhefang-github"><img src="https://avatars.githubusercontent.com/u/34058239?v=4&s=48" width="48" height="48" alt="张哲芳" title="张哲芳"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/joelnishanth"><img src="https://avatars.githubusercontent.com/u/140015627?v=4&s=48" width="48" height="48" alt="OfflynAI" title="OfflynAI"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="Daniel Zou" title="Daniel Zou"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/Kansodata"><img src="https://avatars.githubusercontent.com/u/225288021?v=4&s=48" width="48" height="48" alt="Kansodata" title="Kansodata"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a>
<a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="Ganghyun Kim" title="Ganghyun Kim"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John Rood" title="John Rood"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="Dinakar Sarbada" title="Dinakar Sarbada"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a>
<a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a> <a href="https://github.com/fwhite13"><img src="https://avatars.githubusercontent.com/u/173006051?v=4&s=48" width="48" height="48" alt="Fred White" title="Fred White"/></a> <a href="https://github.com/Anandesh-Sharma"><img src="https://avatars.githubusercontent.com/u/30695364?v=4&s=48" width="48" height="48" alt="Anandesh-Sharma" title="Anandesh-Sharma"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a>
<a href="https://github.com/DevSecTim"><img src="https://avatars.githubusercontent.com/u/2226767?v=4&s=48" width="48" height="48" alt="DevSecTim" title="DevSecTim"/></a> <a href="https://github.com/edincampara"><img src="https://avatars.githubusercontent.com/u/142477787?v=4&s=48" width="48" height="48" alt="edincampara" title="edincampara"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a>
<a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></a> <a href="https://github.com/yassine20011"><img src="https://avatars.githubusercontent.com/u/59234686?v=4&s=48" width="48" height="48" alt="Yassine Amjad" title="Yassine Amjad"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="Frank Harris" title="Frank Harris"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/AytuncYildizli"><img src="https://avatars.githubusercontent.com/u/47717026?v=4&s=48" width="48" height="48" alt="AytuncYildizli" title="AytuncYildizli"/></a>
<a href="https://github.com/KnHack"><img src="https://avatars.githubusercontent.com/u/2346724?v=4&s=48" width="48" height="48" alt="Charlie Niño" title="Charlie Niño"/></a> <a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/junjunjunbong"><img src="https://avatars.githubusercontent.com/u/153147718?v=4&s=48" width="48" height="48" alt="junwon" title="junwon"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/Slats24"><img src="https://avatars.githubusercontent.com/u/42514321?v=4&s=48" width="48" height="48" alt="Slats" title="Slats"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
<a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/Cassius0924"><img src="https://avatars.githubusercontent.com/u/62874592?v=4&s=48" width="48" height="48" alt="Cassius0924" title="Cassius0924"/></a> <a href="https://github.com/0xbrak"><img src="https://avatars.githubusercontent.com/u/181251288?v=4&s=48" width="48" height="48" alt="0xbrak" title="0xbrak"/></a> <a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a>
<a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/albertlieyingadrian"><img src="https://avatars.githubusercontent.com/u/12984659?v=4&s=48" width="48" height="48" alt="albertlieyingadrian" title="albertlieyingadrian"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/ali-aljufairi"><img src="https://avatars.githubusercontent.com/u/85583841?v=4&s=48" width="48" height="48" alt="ali-aljufairi" title="ali-aljufairi"/></a> <a href="https://github.com/altaywtf"><img src="https://avatars.githubusercontent.com/u/9790196?v=4&s=48" width="48" height="48" alt="altaywtf" title="altaywtf"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/avacadobanana352"><img src="https://avatars.githubusercontent.com/u/263496834?v=4&s=48" width="48" height="48" alt="avacadobanana352" title="avacadobanana352"/></a>
<a href="https://github.com/barronlroth"><img src="https://avatars.githubusercontent.com/u/5567884?v=4&s=48" width="48" height="48" alt="barronlroth" title="barronlroth"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bigwest60"><img src="https://avatars.githubusercontent.com/u/12373979?v=4&s=48" width="48" height="48" alt="bigwest60" title="bigwest60"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/dutifulbob"><img src="https://avatars.githubusercontent.com/u/261991368?v=4&s=48" width="48" height="48" alt="dutifulbob" title="dutifulbob"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/gittb"><img src="https://avatars.githubusercontent.com/u/8284364?v=4&s=48" width="48" height="48" alt="gittb" title="gittb"/></a>
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/junsuwhy"><img src="https://avatars.githubusercontent.com/u/4645498?v=4&s=48" width="48" height="48" alt="junsuwhy" title="junsuwhy"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="Ethan Palm" title="Ethan Palm"/></a>
<a href="https://github.com/yingchunbai"><img src="https://avatars.githubusercontent.com/u/33477283?v=4&s=48" width="48" height="48" alt="yingchunbai" title="yingchunbai"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="Dan Ballance" title="Dan Ballance"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="Eric Su" title="Eric Su"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/efe-buken"><img src="https://avatars.githubusercontent.com/u/262546946?v=4&s=48" width="48" height="48" alt="efe-buken" title="efe-buken"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/easternbloc"><img src="https://avatars.githubusercontent.com/u/92585?v=4&s=48" width="48" height="48" alt="easternbloc" title="easternbloc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Mind-Dragon"><img src="https://avatars.githubusercontent.com/u/262945885?v=4&s=48" width="48" height="48" alt="Mind-Dragon" title="Mind-Dragon"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/mgratch"><img src="https://avatars.githubusercontent.com/u/2238658?v=4&s=48" width="48" height="48" alt="Marc Gratch" title="Marc Gratch"/></a> <a href="https://github.com/JackyWay"><img src="https://avatars.githubusercontent.com/u/53031570?v=4&s=48" width="48" height="48" alt="JackyWay" title="JackyWay"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a>
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/mujiannan"><img src="https://avatars.githubusercontent.com/u/46643837?v=4&s=48" width="48" height="48" alt="mujiannan" title="mujiannan"/></a> <a href="https://github.com/marcodd23"><img src="https://avatars.githubusercontent.com/u/3519682?v=4&s=48" width="48" height="48" alt="Marco Di Dionisio" title="Marco Di Dionisio"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/afern247"><img src="https://avatars.githubusercontent.com/u/34192856?v=4&s=48" width="48" height="48" alt="afern247" title="afern247"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a>
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
</p>

View File

@@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives:
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it.
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
@@ -30,6 +30,45 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
### Report Acceptance Gate (Triage Fast Path)
For fastest triage, include all of the following:
- Exact vulnerable path (`file`, function, and line range) on a current revision.
- Tested version details (OpenClaw version and/or commit SHA).
- Reproducible PoC against latest `main` or latest released version.
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities.
Reports that miss these requirements may be closed as `invalid` or `no-action`.
### Common False-Positive Patterns
These are frequently reported but are typically closed with no code change:
- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope).
- Operator-intended local features (for example TUI local `!` shell) presented as remote injection.
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- 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 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.
- 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.
- Missing HSTS findings on default local/loopback deployments.
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
### Duplicate Report Handling
- Search existing advisories before filing.
- Include likely duplicate GHSA IDs in your report when applicable.
- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report.
## Security & Trust
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
@@ -43,12 +82,46 @@ The best way to help the project right now is by sending PRs.
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
## Operator Trust Model (Important)
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.
- 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.
- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway.
- 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.
- 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.
## Trusted Plugin Concept (Core)
Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host.
- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary.
- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin.
## Out of Scope
- Public Internet Exposure
- Using OpenClaw in ways that the docs recommend not to
- Deployments where mutually untrusted/adversarial operators share one gateway host and config
- Prompt injection attacks
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
- 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.
- 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.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
## Deployment Assumptions
@@ -58,6 +131,43 @@ OpenClaw security guidance assumes:
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user).
## One-User Trust Model (Personal Assistant)
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
## Agent and Model Assumptions
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
## Gateway and Node trust concept
OpenClaw separates routing from execution, but both remain inside the same operator trust boundary:
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
## Workspace Memory Trust Boundary
`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state.
- If someone can edit workspace memory files, they already crossed the trusted operator boundary.
- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary.
- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it."
- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways.
## Plugin Trust Boundary
@@ -67,6 +177,23 @@ Plugins/extensions are loaded **in-process** with the Gateway and are treated as
- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary.
- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids.
## Temp Folder Boundary (Media/Sandbox)
OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts:
- Preferred temp root: `/tmp/openclaw` (when available and safe on the host).
- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-<uid>` on multi-user hosts).
Security boundary notes:
- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root.
- Arbitrary host tmp paths are not treated as trusted media roots.
- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files.
- Enforcement reference points:
- temp root resolver: `src/infra/tmp-openclaw-dir.ts`
- SDK temp helpers: `src/plugin-sdk/temp-path.ts`
- messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs`
## Operational Guidance
For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see:
@@ -76,9 +203,17 @@ For threat model + hardening guidance (including `openclaw security audit --deep
### Tool filesystem hardening
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
### Sub-agent delegation hardening
- Keep `sessions_spawn` denied unless you explicitly need delegated runs.
- Keep `agents.list[].subagents.allowAgents` narrow, and only include agents with sandbox settings you trust.
- When delegation must stay sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `inherit`).
- `sandbox: "require"` rejects the spawn unless the target child runtime is sandboxed.
- This prevents a less-restricted session from delegating work into an unsandboxed child by mistake.
### Web Interface Safety
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.

View File

@@ -3,142 +3,142 @@
<channel>
<title>OpenClaw</title>
<item>
<title>2026.2.14</title>
<pubDate>Sun, 15 Feb 2026 04:24:34 +0100</pubDate>
<title>2026.3.1</title>
<pubDate>Mon, 02 Mar 2026 04:40:59 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602140</sparkle:version>
<sparkle:shortVersionString>2026.2.14</sparkle:shortVersionString>
<sparkle:version>2026030190</sparkle:version>
<sparkle:shortVersionString>2026.3.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.14</h2>
<description><![CDATA[<h2>OpenClaw 2026.3.1</h2>
<h3>Changes</h3>
<ul>
<li>Telegram: add poll sending via <code>openclaw message poll</code> (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.</li>
<li>Slack/Discord: add <code>dmPolicy</code> + <code>allowFrom</code> config aliases for DM access control; legacy <code>dm.policy</code> + <code>dm.allowFrom</code> keys remain supported and <code>openclaw doctor --fix</code> can migrate them.</li>
<li>Discord: allow exec approval prompts to target channels or both DM+channel via <code>channels.discord.execApprovals.target</code>. (#16051) Thanks @leonnardo.</li>
<li>Sandbox: add <code>sandbox.browser.binds</code> to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.</li>
<li>Discord: add debug logging for message routing decisions to improve <code>--debug</code> tracing. (#16202) Thanks @jayleekr.</li>
<li>Agents/Thinking defaults: set <code>adaptive</code> as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at <code>low</code> unless explicitly configured.</li>
<li>Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (<code>/health</code>, <code>/healthz</code>, <code>/ready</code>, <code>/readyz</code>) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.</li>
<li>Android/Nodes: add <code>camera.list</code>, <code>device.permissions</code>, <code>device.health</code>, and <code>notifications.actions</code> (<code>open</code>/<code>dismiss</code>/<code>reply</code>) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.</li>
<li>Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (<code>idleHours</code>, default 24h) plus optional hard <code>maxAgeHours</code> lifecycle controls, and add <code>/session idle</code> + <code>/session max-age</code> commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.</li>
<li>Telegram/DM topics: add per-DM <code>direct</code> + topic config (allowlists, <code>dmPolicy</code>, <code>skills</code>, <code>systemPrompt</code>, <code>requireTopic</code>), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.</li>
<li>Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.</li>
<li>OpenAI/Streaming transport: make <code>openai</code> Responses WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (<code>store</code> + <code>context_management</code>) on the WS path.</li>
<li>Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.</li>
<li>Android/Nodes parity: add <code>system.notify</code>, <code>photos.latest</code>, <code>contacts.search</code>/<code>contacts.add</code>, <code>calendar.events</code>/<code>calendar.add</code>, and <code>motion.activity</code>/<code>motion.pedometer</code>, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.</li>
<li>CLI/Config: add <code>openclaw config file</code> to print the active config file path resolved from <code>OPENCLAW_CONFIG_PATH</code> or the default location. (#26256) thanks @cyb1278588254.</li>
<li>Feishu/Docx tables + uploads: add <code>feishu_doc</code> actions for Docx table creation/cell writing (<code>create_table</code>, <code>write_table_cells</code>, <code>create_table_with_values</code>) and image/file uploads (<code>upload_image</code>, <code>upload_file</code>) with stricter create/upload error handling for missing <code>document_id</code> and placeholder cleanup failures. (#20304) Thanks @xuhao1.</li>
<li>Feishu/Reactions: add inbound <code>im.message.reaction.created_v1</code> handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.</li>
<li>Feishu/Chat tooling: add <code>feishu_chat</code> tool actions for chat info and member queries, with configurable enablement under <code>channels.feishu.tools.chat</code>. (#14674) Thanks @liuweifly.</li>
<li>Feishu/Doc permissions: support optional owner permission grant fields on <code>feishu_doc</code> create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.</li>
<li>Web UI/i18n: add German (<code>de</code>) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.</li>
<li>Tools/Diffs: add a new optional <code>diffs</code> plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.</li>
<li>Memory/LanceDB: support custom OpenAI <code>baseUrl</code> and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.</li>
<li>ACP/ACPX streaming: pin ACPX plugin support to <code>0.1.15</code>, add configurable ACPX command/version probing, and streamline ACP stream delivery (<code>final_only</code> default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.</li>
<li>Shell env markers: set <code>OPENCLAW_SHELL</code> across shell-like runtimes (<code>exec</code>, <code>acp</code>, <code>acp-client</code>, <code>tui-local</code>) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.</li>
<li>Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (<code>--light-context</code> for cron agent turns and <code>agents.*.heartbeat.lightContext</code> for heartbeat), keeping only <code>HEARTBEAT.md</code> for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.</li>
<li>OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (<code>response.create</code> with <code>generate:false</code>), enable it by default for <code>openai/*</code>, and expose <code>params.openaiWsWarmup</code> for per-model enable/disable control.</li>
<li>Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (<code>task_completion</code>) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured <code>internalEvents</code>.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Node exec approval payloads now require <code>systemRunPlan</code>. <code>host=node</code> approval requests without that plan are rejected.</li>
<li><strong>BREAKING:</strong> Node <code>system.run</code> execution now pins path-token commands to the canonical executable path (<code>realpath</code>) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example <code>tr</code>) must now accept canonical paths (for example <code>/usr/bin/tr</code>).</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI/Plugins: ensure <code>openclaw message send</code> exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.</li>
<li>CLI/Plugins: run registered plugin <code>gateway_stop</code> hooks before <code>openclaw message</code> exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.</li>
<li>WhatsApp: honor per-account <code>dmPolicy</code> overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.</li>
<li>Telegram: when <code>channels.telegram.commands.native</code> is <code>false</code>, exclude plugin commands from <code>setMyCommands</code> menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.</li>
<li>LINE: return 200 OK for Developers Console "Verify" requests (<code>{"events":[]}</code>) without <code>X-Line-Signature</code>, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.</li>
<li>Cron: deliver text-only output directly when <code>delivery.to</code> is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.</li>
<li>Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.</li>
<li>Media: accept <code>MEDIA:</code>-prefixed paths (lenient whitespace) when loading outbound media to prevent <code>ENOENT</code> for tool-returned local media paths. (#13107) Thanks @mcaxtr.</li>
<li>Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.</li>
<li>Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)</li>
<li>Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit <code>workspaceDir</code>. (#16722)</li>
<li>BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.</li>
<li>CLI: fix lazy core command registration so top-level maintenance commands (<code>doctor</code>, <code>dashboard</code>, <code>reset</code>, <code>uninstall</code>) resolve correctly instead of exposing a non-functional <code>maintenance</code> placeholder command.</li>
<li>CLI/Dashboard: when <code>gateway.bind=lan</code>, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.</li>
<li>TUI/Gateway: resolve local gateway target URL from <code>gateway.bind</code> mode (tailnet/lan) instead of hardcoded localhost so <code>openclaw tui</code> connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.</li>
<li>TUI: honor explicit <code>--session <key></code> in <code>openclaw tui</code> even when <code>session.scope</code> is <code>global</code>, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.</li>
<li>TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.</li>
<li>TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.</li>
<li>TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.</li>
<li>TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.</li>
<li>TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.</li>
<li>TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.</li>
<li>TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.</li>
<li>TUI/Hooks: pass explicit reset reason (<code>new</code> vs <code>reset</code>) through <code>sessions.reset</code> and emit internal command hooks for gateway-triggered resets so <code>/new</code> hook workflows fire in TUI/webchat.</li>
<li>Cron: prevent <code>cron list</code>/<code>cron status</code> from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.</li>
<li>Cron: repair missing/corrupt <code>nextRunAtMs</code> for the updated job without globally recomputing unrelated due jobs during <code>cron update</code>. (#15750)</li>
<li>Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale <code>runningAtMs</code> markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.</li>
<li>Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as <code>guild=dm</code>. Thanks @thewilloftheshadow.</li>
<li>Discord: treat empty per-guild <code>channels: {}</code> config maps as no channel allowlist (not deny-all), so <code>groupPolicy: "open"</code> guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.</li>
<li>Models/CLI: guard <code>models status</code> string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.</li>
<li>Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.</li>
<li>Gateway/Sessions: abort active embedded runs and clear queued session work before <code>sessions.reset</code>, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.</li>
<li>Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.</li>
<li>Agents: add a safety timeout around embedded <code>session.compact()</code> to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.</li>
<li>Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including <code>session_status</code> model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.</li>
<li>Agents/Process/Bootstrap: preserve unbounded <code>process log</code> offset-only pagination (default tail applies only when both <code>offset</code> and <code>limit</code> are omitted) and enforce strict <code>bootstrapTotalMaxChars</code> budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.</li>
<li>Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing <code>BOOTSTRAP.md</code> once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.</li>
<li>Agents/Workspace: create <code>BOOTSTRAP.md</code> when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.</li>
<li>Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.</li>
<li>Agents: treat empty-stream provider failures (<code>request ended without sending any chunks</code>) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.</li>
<li>Agents: treat <code>read</code> tool <code>file_path</code> arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.</li>
<li>Ollama/Agents: avoid forcing <code><final></code> tag enforcement for Ollama models, which could suppress all output as <code>(no output)</code>. (#16191) Thanks @Glucksberg.</li>
<li>Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.</li>
<li>Skills: watch <code>SKILL.md</code> only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.</li>
<li>Memory/QMD: make <code>memory status</code> read-only by skipping QMD boot update/embed side effects for status-only manager checks.</li>
<li>Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.</li>
<li>Memory/Builtin: keep <code>memory status</code> dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.</li>
<li>Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological <code>qmd</code> command output.</li>
<li>Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.</li>
<li>Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.</li>
<li>Memory/QMD: pass result limits to <code>search</code>/<code>vsearch</code> commands so QMD can cap results earlier.</li>
<li>Memory/QMD: avoid reading full markdown files when a <code>from/lines</code> window is requested in QMD reads.</li>
<li>Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.</li>
<li>Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy <code>stdout</code>.</li>
<li>Memory/QMD: treat prefixed <code>no results found</code> marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.</li>
<li>Memory/QMD: avoid multi-collection <code>query</code> ranking corruption by running one <code>qmd query -c <collection></code> per managed collection and merging by best score (also used for <code>search</code>/<code>vsearch</code> fallback-to-query). (#16740) Thanks @volarian-vai.</li>
<li>Memory/QMD: detect null-byte <code>ENOTDIR</code> update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.</li>
<li>Memory/QMD/Security: add <code>rawKeyPrefix</code> support for QMD scope rules and preserve legacy <code>keyPrefix: "agent:..."</code> matching, preventing scoped deny bypass when operators match agent-prefixed session keys.</li>
<li>Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.</li>
<li>Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.</li>
<li>Security/Memory-LanceDB: require explicit <code>autoCapture: true</code> opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.</li>
<li>Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.</li>
<li>Gateway/Memory: clean up <code>agentRunSeq</code> tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.</li>
<li>Auto-reply/Memory: bound <code>ABORT_MEMORY</code> growth by evicting oldest entries and deleting reset (<code>false</code>) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.</li>
<li>Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.</li>
<li>Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.</li>
<li>Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.</li>
<li>Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.</li>
<li>Media/Security: allow local media reads from OpenClaw state <code>workspace/</code> and <code>sandboxes/</code> roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.</li>
<li>Media/Security: harden local media allowlist bypasses by requiring an explicit <code>readFile</code> override when callers mark paths as validated, and reject filesystem-root <code>localRoots</code> entries. (#16739)</li>
<li>Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.</li>
<li>Security/BlueBubbles: require explicit <code>mediaLocalRoots</code> allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.</li>
<li>Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.</li>
<li>Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.</li>
<li>Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.</li>
<li>Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.</li>
<li>Security/Hooks: restrict hook transform modules to <code>~/.openclaw/hooks/transforms</code> (prevents path traversal/escape module loads via config). Config note: <code>hooks.transformsDir</code> must now be within that directory. Thanks @akhmittra.</li>
<li>Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).</li>
<li>Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.</li>
<li>Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.</li>
<li>Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.</li>
<li>Security/Slack: compute command authorization for DM slash commands even when <code>dmPolicy=open</code>, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.</li>
<li>Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.</li>
<li>Security/Google Chat: deprecate <code>users/<email></code> allowlists (treat <code>users/...</code> as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.</li>
<li>Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.</li>
<li>Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject <code>@username</code> principals), auto-resolve <code>@username</code> to IDs in <code>openclaw doctor --fix</code> (when possible), and warn in <code>openclaw security audit</code> when legacy configs contain usernames. Thanks @vincentkoc.</li>
<li>Telegram/Security: reject Telegram webhook startup when <code>webhookSecret</code> is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.</li>
<li>Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).</li>
<li>Telegram: set webhook callback timeout handling to <code>onTimeout: "return"</code> (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.</li>
<li>Signal: preserve case-sensitive <code>group:</code> target IDs during normalization so mixed-case group IDs no longer fail with <code>Group not found</code>. (#16748) Thanks @repfigit.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.</li>
<li>Security/Agents: enforce workspace-root path bounds for <code>apply_patch</code> in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.</li>
<li>Security/Agents: enforce symlink-escape checks for <code>apply_patch</code> delete hunks under <code>workspaceOnly</code>, while still allowing deleting the symlink itself. Thanks @p80n-sec.</li>
<li>Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.</li>
<li>macOS: hard-limit unkeyed <code>openclaw://agent</code> deep links and ignore <code>deliver</code> / <code>to</code> / <code>channel</code> unless a valid unattended key is provided. Thanks @Cillian-Collins.</li>
<li>Scripts/Security: validate GitHub logins and avoid shell invocation in <code>scripts/update-clawtributors.ts</code> to prevent command injection via malicious commit records. Thanks @scanleale.</li>
<li>Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).</li>
<li>Security/Gateway: harden tool-supplied <code>gatewayUrl</code> overrides by restricting them to loopback or the configured <code>gateway.remote.url</code>. Thanks @p80n-sec.</li>
<li>Security/Gateway: block <code>system.execApprovals.*</code> via <code>node.invoke</code> (use <code>exec.approvals.node.*</code> instead). Thanks @christos-eth.</li>
<li>Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Gateway: stop returning raw resolved config values in <code>skills.status</code> requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.</li>
<li>Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.</li>
<li>Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.</li>
<li>Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.</li>
<li>Security/Node Host: enforce <code>system.run</code> rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.</li>
<li>Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.</li>
<li>Security/Exec: harden PATH handling by disabling project-local <code>node_modules/.bin</code> bootstrapping by default, disallowing node-host <code>PATH</code> overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.</li>
<li>Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: <code>channels.tlon.allowPrivateNetwork</code>). Thanks @p80n-sec.</li>
<li>Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without <code>telnyx.publicKey</code> are now rejected unless <code>skipSignatureVerification</code> is enabled. Thanks @p80n-sec.</li>
<li>Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.</li>
<li>Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.</li>
<li>Android/Nodes reliability: reject <code>facing=both</code> when <code>deviceId</code> is set to avoid mislabeled duplicate captures, allow notification <code>open</code>/<code>reply</code> on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.</li>
<li>Windows/Plugin install: avoid <code>spawn EINVAL</code> on Windows npm/npx invocations by resolving to <code>node</code> + npm CLI scripts instead of spawning <code>.cmd</code> directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.</li>
<li>LINE/Voice transcription: classify M4A voice media as <code>audio/mp4</code> (not <code>video/mp4</code>) by checking the MPEG-4 <code>ftyp</code> major brand (<code>M4A </code> / <code>M4B </code>), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct <code>accountId</code> instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.</li>
<li>Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.</li>
<li>Android/Photos permissions: declare Android 14+ selected-photo access permission (<code>READ_MEDIA_VISUAL_USER_SELECTED</code>) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.</li>
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
<li>Feishu/Reply media attachments: send Feishu reply <code>mediaUrl</code>/<code>mediaUrls</code> payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when <code>mediaUrls</code> is empty. (#28959) Thanks @icesword0760.</li>
<li>Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (<code>SLACK_USER_TOKEN</code> env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.</li>
<li>Feishu/Outbound session routing: stop assuming bare <code>oc_</code> identifiers are always group chats, honor explicit <code>dm:</code>/<code>group:</code> prefixes for <code>oc_</code> chat IDs, and default ambiguous bare <code>oc_</code> targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.</li>
<li>Feishu/Group session routing: add configurable group session scopes (<code>group</code>, <code>group_sender</code>, <code>group_topic</code>, <code>group_topic_sender</code>) with legacy <code>topicSessionMode=enabled</code> compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.</li>
<li>Feishu/Reply-in-thread routing: add <code>replyInThread</code> config (<code>disabled|enabled</code>) for group replies, propagate <code>reply_in_thread</code> across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.</li>
<li>Feishu/Probe status caching: cache successful <code>probeFeishu()</code> bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.</li>
<li>Feishu/Opus media send type: send <code>.opus</code> attachments with <code>msg_type: "audio"</code> (instead of <code>"media"</code>) so Feishu voice messages deliver correctly while <code>.mp4</code> remains <code>msg_type: "media"</code> and documents remain <code>msg_type: "file"</code>. (#28269) Thanks @Glucksberg.</li>
<li>Feishu/Mobile video media type: treat inbound <code>message_type: "media"</code> as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.</li>
<li>Feishu/Inbound sender fallback: fall back to <code>sender_id.user_id</code> when <code>sender_id.open_id</code> is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.</li>
<li>Feishu/Reply context metadata: include inbound <code>parent_id</code> and <code>root_id</code> as <code>ReplyToId</code>/<code>RootMessageId</code> in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.</li>
<li>Feishu/Post embedded media: extract <code>media</code> tags from inbound rich-text (<code>post</code>) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.</li>
<li>Feishu/Local media sends: propagate <code>mediaLocalRoots</code> through Feishu outbound media sending into <code>loadWebMedia</code> so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.</li>
<li>Feishu/Group wildcard policy fallback: honor <code>channels.feishu.groups["*"]</code> when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.</li>
<li>Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (<code>image</code> stays <code>image</code>, non-image maps to <code>file</code>) to prevent reintroducing unsupported Feishu <code>type=audio</code> fetches. (#16311, #8746) Thanks @Yaxuan42.</li>
<li>TTS/Voice bubbles: use opus output and enable <code>audioAsVoice</code> routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.</li>
<li>Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.</li>
<li>Android/Nodes notification wake flow: enable Android <code>system.notify</code> default allowlist, emit <code>notifications.changed</code> events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.</li>
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
<li>Feishu/Inbound rich-text parsing: preserve <code>share_chat</code> payload summaries when available and add explicit parsing for rich-text <code>code</code>/<code>code_block</code>/<code>pre</code> tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.</li>
<li>Feishu/Post markdown parsing: parse rich-text <code>post</code> payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.</li>
<li>Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.</li>
<li>Slack/Native commands: register Slack native status as <code>/agentstatus</code> (Slack-reserved <code>/status</code>) so manifest slash command registration stays valid while text <code>/status</code> still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.</li>
<li>Android/Camera clip: remove <code>camera.clip</code> HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive <code>maxWidth</code> values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.</li>
<li>Android/Gateway canvas capability refresh: send <code>node.canvas.capability.refresh</code> with object <code>params</code> (<code>{}</code>) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.</li>
<li>Gateway/Control UI origins: honor <code>gateway.controlUi.allowedOrigins: ["*"]</code> wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.</li>
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
<li>Agents/Sessions list transcript paths: handle missing/non-string/relative <code>sessions.list.path</code> values and per-agent <code>{agentId}</code> templates when deriving <code>transcriptPath</code>, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.</li>
<li>Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.</li>
<li>CLI/Install: add an npm-link fallback to fix CLI startup <code>Permission denied</code> failures (<code>exit 127</code>) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.</li>
<li>Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.</li>
<li>Plugins/NPM spec install: fix npm-spec plugin installs when <code>npm pack</code> output is empty by detecting newly created <code>.tgz</code> archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.</li>
<li>Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.</li>
<li>Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.</li>
<li>Gateway/macOS supervised restart: actively <code>launchctl kickstart -k</code> during intentional supervised restarts to bypass LaunchAgent <code>ThrottleInterval</code> delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.</li>
<li>Daemon/macOS TLS certs: default LaunchAgent service env <code>NODE_EXTRA_CA_CERTS</code> to <code>/etc/ssl/cert.pem</code> (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.</li>
<li>Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Feishu/Reaction notifications: add <code>channels.feishu.reactionNotifications</code> (<code>off | own | all</code>, default <code>own</code>) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.</li>
<li>Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (<code>429</code>, <code>99991400</code>, <code>99991403</code>) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.</li>
<li>Feishu/Zalo runtime logging: replace direct <code>console.log/error</code> usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.</li>
<li>Feishu/Group sender allowlist fallback: add global <code>channels.feishu.groupSenderAllowFrom</code> sender authorization for group chats, with per-group <code>groups.<id>.allowFrom</code> precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.</li>
<li>Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.</li>
<li>Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when <code>document.convert</code> hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.</li>
<li>Feishu/API quota controls: add <code>typingIndicator</code> and <code>resolveSenderNames</code> config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.</li>
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</li>
<li>Sessions/Internal routing: preserve established external <code>lastTo</code>/<code>lastChannel</code> routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.</li>
<li>Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.</li>
<li>Auto-reply/NO_REPLY: strip <code>NO_REPLY</code> token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.</li>
<li>Update/Global npm: fallback to <code>--omit=optional</code> when global <code>npm update</code> fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.</li>
<li>Inbound metadata/Multi-account routing: include <code>account_id</code> in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.</li>
<li>Model directives/Auth profiles: split <code>/model</code> profile suffixes at the first <code>@</code> after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.</li>
<li>Cron/Delivery mode none: send explicit <code>delivery: { mode: "none" }</code> from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.</li>
<li>Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with <code>think=off</code> to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.</li>
<li>Ollama/Embedded runner base URL precedence: prioritize configured provider <code>baseUrl</code> over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.</li>
<li>Agents/Failover reason classification: avoid false rate-limit classification from incidental <code>tpm</code> substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.</li>
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
<li>Gateway/WS: close repeated post-handshake <code>unauthorized role:*</code> request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.</li>
<li>Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.</li>
<li>CLI/Ollama config: allow <code>config set</code> for Ollama <code>apiKey</code> without predeclared provider config. (#29299) Thanks @vincentkoc.</li>
<li>Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.</li>
<li>Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.</li>
<li>Agents/Ollama: demote empty-discovery logging from <code>warn</code> to <code>debug</code> to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.</li>
<li>fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.</li>
<li>Docker/Image permissions: normalize <code>/app/extensions</code>, <code>/app/.agent</code>, and <code>/app/.agents</code> to directory mode <code>755</code> and file mode <code>644</code> during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.</li>
<li>OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty <code>baseUrl</code> as non-direct, honor <code>compat.supportsStore=false</code>, and auto-inject server-side compaction <code>context_management</code> for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.</li>
<li>Sandbox/Browser Docker: pass <code>OPENCLAW_BROWSER_NO_SANDBOX=1</code> to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.</li>
<li>Usage normalization: clamp negative prompt/input token values to zero (including <code>prompt_tokens</code> alias inputs) so <code>/usage</code> and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Secrets/Auth profiles: normalize inline SecretRef <code>token</code>/<code>key</code> values to canonical <code>tokenRef</code>/<code>keyRef</code> before persistence, and keep explicit <code>keyRef</code> precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.</li>
<li>Tools/Edit workspace boundary errors: preserve the real <code>Path escapes workspace root</code> failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.</li>
<li>Browser/Open & navigate: accept <code>url</code> as an alias parameter for <code>open</code> and <code>navigate</code>. (#29260) Thanks @vincentkoc.</li>
<li>Codex/Usage window: label weekly usage window as <code>Week</code> instead of <code>Day</code>. (#26267) Thanks @Sid-Qin.</li>
<li>Signal/Sync message null-handling: treat <code>syncMessage</code> presence (including <code>null</code>) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Infra/fs-safe: sanitize directory-read failures so raw <code>EISDIR</code> text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.</li>
<li>Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false <code>cannot create directories</code> failures in sandbox write mode. (#30610) Thanks @glitch418x.</li>
<li>Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.</li>
<li>Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (<code>198.18.0.0/15</code>) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.</li>
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</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.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
</item>
<item>
<title>2026.2.15</title>
@@ -209,155 +209,106 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.21</title>
<pubDate>Sat, 21 Feb 2026 17:55:48 +0100</pubDate>
<title>2026.2.26</title>
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>13056</sparkle:version>
<sparkle:shortVersionString>2026.2.21</sparkle:shortVersionString>
<sparkle:version>202602260</sparkle:version>
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.21</h2>
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
<h3>Changes</h3>
<ul>
<li>Models/Google: add Gemini 3.1 support (<code>google/gemini-3.1-pro-preview</code>).</li>
<li>Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to <code>volcengine-api-key</code>. (#7967) Thanks @funmore123.</li>
<li>Channels/CLI: add per-account/channel <code>defaultTo</code> outbound routing fallback so <code>openclaw agent --deliver</code> can send without explicit <code>--reply-to</code> when a default target is configured. (#16985) Thanks @KirillShchetinin.</li>
<li>Channels: allow per-channel model overrides via <code>channels.modelByChannel</code> and note them in /status. Thanks @thewilloftheshadow.</li>
<li>Telegram/Streaming: simplify preview streaming config to <code>channels.telegram.streaming</code> (boolean), auto-map legacy <code>streamMode</code> values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.</li>
<li>Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.</li>
<li>Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Discord/Voice: add voice channel join/leave/status via <code>/vc</code>, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.</li>
<li>Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.</li>
<li>Discord: support updating forum <code>available_tags</code> via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.</li>
<li>Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.</li>
<li>Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.</li>
<li>iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.</li>
<li>iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.</li>
<li>iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.</li>
<li>Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.</li>
<li>MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.</li>
<li>Agents/Subagents: default subagent spawn depth now uses shared <code>maxSpawnDepth=2</code>, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.</li>
<li>Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (<code>ownerDisplaySecret</code>) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.</li>
<li>Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.</li>
<li>Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.</li>
<li>Dependencies/Unused Dependencies: remove or scope unused root and extension deps (<code>@larksuiteoapi/node-sdk</code>, <code>signal-utils</code>, <code>ollama</code>, <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, <code>@microsoft/agents-hosting-express</code>, <code>@microsoft/agents-hosting-extensions-teams</code>, and plugin-local <code>openclaw</code> devDeps in <code>extensions/open-prose</code>, <code>extensions/lobster</code>, and <code>extensions/llm-task</code>). (#22471, #22495) Thanks @vincentkoc.</li>
<li>Dependencies/A2UI: harden dependency resolution after root cleanup (resolve <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, and <code>signal-utils</code> from workspace/root) and simplify bundling fallback behavior, including <code>pnpm dlx rolldown</code> compatibility. (#22481, #22507) Thanks @vincentkoc.</li>
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit <code>retry_limit</code> error payload when retries never converge, preventing unbounded internal retry cycles (<code>GHSA-76m6-pj3w-v7mf</code>).</li>
<li>Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless <code>getUpdates</code> conflict loops.</li>
<li>Agents/Tool images: include source filenames in <code>agents/tool-images</code> resize logs so compression events can be traced back to specific files.</li>
<li>Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.</li>
<li>Models/Kimi-Coding: add missing implicit provider template for <code>kimi-coding</code> with correct <code>anthropic-messages</code> API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)</li>
<li>Auto-reply/Tools: forward <code>senderIsOwner</code> through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.</li>
<li>Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.</li>
<li>Memory/QMD: respect per-agent <code>memorySearch.enabled=false</code> during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (<code>search</code>/<code>vsearch</code>/<code>query</code>) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip <code>qmd embed</code> in BM25-only <code>search</code> mode (including <code>memory index --force</code>), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.</li>
<li>Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so <code>onSearch</code>/<code>onSessionStart</code> no longer fail with <code>database is not open</code> in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.</li>
<li>Providers/Copilot: drop persisted assistant <code>thinking</code> blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid <code>thinkingSignature</code> payloads. (#19459) Thanks @jackheuberger.</li>
<li>Providers/Copilot: add <code>claude-sonnet-4.6</code> and <code>claude-sonnet-4.5</code> to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.</li>
<li>Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example <code>whatsapp</code>) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.</li>
<li>Status: include persisted <code>cacheRead</code>/<code>cacheWrite</code> in session summaries so compact <code>/status</code> output consistently shows cache hit percentages from real session data.</li>
<li>Heartbeat/Cron: restore interval heartbeat behavior so missing <code>HEARTBEAT.md</code> no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.</li>
<li>WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured <code>allowFrom</code> recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.</li>
<li>Heartbeat/Active hours: constrain active-hours <code>24</code> sentinel parsing to <code>24:00</code> in time validation so invalid values like <code>24:30</code> are rejected early. (#21410) thanks @adhitShet.</li>
<li>Heartbeat: treat <code>activeHours</code> windows with identical <code>start</code>/<code>end</code> times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.</li>
<li>CLI/Pairing: default <code>pairing list</code> and <code>pairing approve</code> to the sole available pairing channel when omitted, so TUI-only setups can recover from <code>pairing required</code> without guessing channel arguments. (#21527) Thanks @losts1.</li>
<li>TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return <code>pairing required</code>, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.</li>
<li>TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.</li>
<li>TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when <code>showOk</code> is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.</li>
<li>TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with <code>RangeError: Maximum call stack size exceeded</code>. (#18068) Thanks @JaniJegoroff.</li>
<li>Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.</li>
<li>Memory/Tools: return explicit <code>unavailable</code> warnings/actions from <code>memory_search</code> when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.</li>
<li>Session/Startup: require the <code>/new</code> and <code>/reset</code> greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.</li>
<li>Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing <code>provider:default</code> mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.</li>
<li>Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.</li>
<li>Slack: pass <code>recipient_team_id</code> / <code>recipient_user_id</code> through Slack native streaming calls so <code>chat.startStream</code>/<code>appendStream</code>/<code>stopStream</code> work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.</li>
<li>CLI/Config: add canonical <code>--strict-json</code> parsing for <code>config set</code> and keep <code>--json</code> as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.</li>
<li>CLI: keep <code>openclaw -v</code> as a root-only version alias so subcommand <code>-v, --verbose</code> flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.</li>
<li>Memory: return empty snippets when <code>memory_get</code>/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.</li>
<li>Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.</li>
<li>Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal <code><think></code> tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.</li>
<li>Telegram/Streaming: restore 30-char first-preview debounce and scope <code>NO_REPLY</code> prefix suppression to partial sentinel fragments so normal <code>No...</code> text is not filtered. (#22613) thanks @obviyus.</li>
<li>Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.</li>
<li>Discord/Streaming: apply <code>replyToMode: first</code> only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.</li>
<li>Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.</li>
<li>Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.</li>
<li>Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.</li>
<li>Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.</li>
<li>Auto-reply/Runner: emit <code>onAgentRunStart</code> only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.</li>
<li>Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.</li>
<li>Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (<code>message_id</code>, <code>message_id_full</code>, <code>reply_to_id</code>, <code>sender_id</code>) into untrusted conversation context. (#20597) Thanks @anisoptera.</li>
<li>iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.</li>
<li>iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.</li>
<li>CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate <code>/v1</code> paths during setup checks. (#21336) Thanks @17jmumford.</li>
<li>iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable <code>nodes invoke</code> pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.</li>
<li>Gateway/Auth: require <code>gateway.trustedProxies</code> to include a loopback proxy address when <code>auth.mode="trusted-proxy"</code> and <code>bind="loopback"</code>, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.</li>
<li>Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured <code>gateway.trustedProxies</code>. (#20097) thanks @xinhuagu.</li>
<li>Gateway/Auth: allow authenticated clients across roles/scopes to call <code>health</code> while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.</li>
<li>Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.</li>
<li>Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.</li>
<li>Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.</li>
<li>Gateway/Pairing: clear persisted paired-device state when the gateway client closes with <code>device token mismatch</code> (<code>1008</code>) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.</li>
<li>Gateway/Config: allow <code>gateway.customBindHost</code> in strict config validation when <code>gateway.bind="custom"</code> so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.</li>
<li>Gateway/Pairing: tolerate legacy paired devices missing <code>roles</code>/<code>scopes</code> metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.</li>
<li>Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local <code>openclaw devices</code> fallback recovery for loopback <code>pairing required</code> deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.</li>
<li>Cron: honor <code>cron.maxConcurrentRuns</code> in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.</li>
<li>Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.</li>
<li>Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.</li>
<li>Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.</li>
<li>Agents/Tool display: fix exec cwd suffix inference so <code>pushd ... && popd ... && <command></code> does not keep stale <code>(in <dir>)</code> context in summaries. (#21925) Thanks @Lukavyi.</li>
<li>Tools/web_search: handle xAI Responses API payloads that emit top-level <code>output_text</code> blocks (without a <code>message</code> wrapper) so Grok web_search no longer returns <code>No response</code> for those results. (#20508) Thanks @echoVic.</li>
<li>Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.</li>
<li>Docker/Build: include <code>ownerDisplay</code> in <code>CommandsSchema</code> object-level defaults so Docker <code>pnpm build</code> no longer fails with <code>TS2769</code> during plugin SDK d.ts generation. (#22558) Thanks @obviyus.</li>
<li>Docker/Browser: install Playwright Chromium into <code>/home/node/.cache/ms-playwright</code> and set <code>node:node</code> ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.</li>
<li>Hooks/Session memory: trigger bundled <code>session-memory</code> persistence on both <code>/new</code> and <code>/reset</code> so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.</li>
<li>Dependencies/Agents: bump embedded Pi SDK packages (<code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, <code>@mariozechner/pi-tui</code>) to <code>0.54.0</code>. (#21578) Thanks @Takhoffman.</li>
<li>Config/Agents: expose Pi compaction tuning values <code>agents.defaults.compaction.reserveTokens</code> and <code>agents.defaults.compaction.keepRecentTokens</code> in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via <code>reserveTokensFloor</code>. (#21568) Thanks @Takhoffman.</li>
<li>Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.</li>
<li>Docker: run build steps as the <code>node</code> user and use <code>COPY --chown</code> to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.</li>
<li>Config/Memory: restore schema help/label metadata for hybrid <code>mmr</code> and <code>temporalDecay</code> settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.</li>
<li>Skills/SonosCLI: add troubleshooting guidance for <code>sonos discover</code> failures on macOS direct mode (<code>sendto: no route to host</code>) and sandbox network restrictions (<code>bind: operation not permitted</code>). (#21316) Thanks @huntharo.</li>
<li>macOS/Build: default release packaging to <code>BUNDLE_ID=ai.openclaw.mac</code> in <code>scripts/package-mac-dist.sh</code>, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.</li>
<li>Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.</li>
<li>Anthropic/Agents: preserve required pi-ai default OAuth beta headers when <code>context1m</code> injects <code>anthropic-beta</code>, preventing 401 auth failures for <code>sk-ant-oat-*</code> tokens. (#19789, fixes #19769) Thanks @minupla.</li>
<li>Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.</li>
<li>macOS/Security: evaluate <code>system.run</code> allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via <code>rawCommand</code> chaining. Thanks @tdjackey for reporting.</li>
<li>WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging <code>chatJid</code> + valid <code>messageId</code> pairs. Thanks @aether-ai-agent for reporting.</li>
<li>ACP/Security: escape control and delimiter characters in ACP <code>resource_link</code> title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.</li>
<li>TTS/Security: make model-driven provider switching opt-in by default (<code>messages.tts.modelOverrides.allowProvider=false</code> unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.</li>
<li>Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.</li>
<li>BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.</li>
<li>iOS/Security: force <code>https://</code> for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.</li>
<li>Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.</li>
<li>Gateway/Security: require secure context and paired-device checks for Control UI auth even when <code>gateway.controlUi.allowInsecureAuth</code> is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.</li>
<li>Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.</li>
<li>Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.</li>
<li>Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.</li>
<li>Security/Commands: block prototype-key injection in runtime <code>/debug</code> overrides and require own-property checks for gated command flags (<code>bash</code>, <code>config</code>, <code>debug</code>) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.</li>
<li>Security/Browser: block non-network browser navigation protocols (including <code>file:</code>, <code>data:</code>, and <code>javascript:</code>) while preserving <code>about:blank</code>, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.</li>
<li>Security/Exec: block shell startup-file env injection (<code>BASH_ENV</code>, <code>ENV</code>, <code>BASH_FUNC_*</code>, <code>LD_*</code>, <code>DYLD_*</code>) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.</li>
<li>Security/Exec (Windows): canonicalize <code>cmd.exe /c</code> command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in <code>system.run</code>. Thanks @tdjackey for reporting.</li>
<li>Security/Gateway/Hooks: block <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> traversal in webhook template path resolution to prevent prototype-chain payload data leakage in <code>messageTemplate</code> rendering. (#22213) Thanks @SleuthCo.</li>
<li>Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.</li>
<li>Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.</li>
<li>Security/Net: strip sensitive headers (<code>Authorization</code>, <code>Proxy-Authorization</code>, <code>Cookie</code>, <code>Cookie2</code>) on cross-origin redirects in <code>fetchWithSsrFGuard</code> to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.</li>
<li>Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.</li>
<li>Security/Tools: add per-wrapper random IDs to untrusted-content markers from <code>wrapExternalContent</code>/<code>wrapWebContent</code>, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.</li>
<li>Shared/Security: reject insecure deep links that use <code>ws://</code> non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.</li>
<li>macOS/Security: reject non-loopback <code>ws://</code> remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.</li>
<li>Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.</li>
<li>Security/Dependencies: bump transitive <code>hono</code> usage to <code>4.11.10</code> to incorporate timing-safe authentication comparison hardening for <code>basicAuth</code>/<code>bearerAuth</code> (<code>GHSA-gq3j-xvxp-8hrf</code>). Thanks @vincentkoc.</li>
<li>Security/Gateway: parse <code>X-Forwarded-For</code> with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.</li>
<li>Security/Sandbox: remove default <code>--no-sandbox</code> for the browser container entrypoint, add explicit opt-in via <code>OPENCLAW_BROWSER_NO_SANDBOX</code> / <code>CLAWDBOT_BROWSER_NO_SANDBOX</code>, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.</li>
<li>Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.</li>
<li>Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (<code>openclaw-sandbox-browser</code>), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in <code>openclaw security --audit</code> when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.</li>
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.</li>
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.</li>
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
<li>Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.</li>
<li>Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.</li>
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
<li>Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)</li>
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.</li>
<li>Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.</li>
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
<li>TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)</li>
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
<li>Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)</li>
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.</li>
<li>Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)</li>
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.</li>
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
<li>Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)</li>
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
<li>Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.</li>
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.</li>
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.</li>
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.</li>
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
<li>Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.</li>
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)</li>
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
<li>Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Config includes: harden <code>$include</code> file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.</li>
<li>Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)</li>
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.</li>
<li>Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)</li>
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
<li>Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
<li>iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.</li>
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</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.2.21/OpenClaw-2026.2.21.zip" length="23065599" type="application/octet-stream" sparkle:edSignature="Wg3P8rMvYO3uWoVR7Izxjm5hC5W0C5jCG2dR4WFSe8ULpUUU79YDJc99NMBnl8ym7ZVbelS3kZ0QSg0Wq2GhCw=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
</item>
</channel>
</rss>

View File

@@ -1,13 +1,26 @@
## OpenClaw Node (Android) (internal)
## OpenClaw Android App
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Status: **extremely alpha**. The app is actively being rebuilt from the ground up.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
### Rebuild Checklist
- [x] New 4-step onboarding flow
- [x] Connect tab with `Setup Code` + `Manual` modes
- [x] Encrypted persistence for gateway setup/auth state
- [x] Chat UI restyled
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
- [x] QR code scanning in onboarding
- [x] Performance improvements
- [x] Streaming support in chat UI
- [x] Request camera/location and other permissions in onboarding/settings flow
- [x] Push notifications for gateway/chat status updates
- [x] Security hardening (biometric lock, token handling, safer defaults)
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
@@ -19,23 +32,132 @@ cd apps/android
./gradlew :app:testDebugUnitTest
```
## Kotlin Lint + Format
```bash
pnpm android:lint
pnpm android:format
```
Android framework/resource lint (separate pass):
```bash
pnpm android:lint:android
```
Direct Gradle tasks:
```bash
cd apps/android
./gradlew :app:ktlintCheck :benchmark:ktlintCheck
./gradlew :app:ktlintFormat :benchmark:ktlintFormat
./gradlew :app:lintDebug
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Macrobenchmark (Startup + Frame Timing)
```bash
cd apps/android
./gradlew :benchmark:connectedDebugAndroidTest
```
Reports are written under:
- `apps/android/benchmark/build/reports/androidTests/connected/`
## Perf CLI (low-noise)
Deterministic startup measurement + hotspot extraction with compact CLI output:
```bash
cd apps/android
./scripts/perf-startup-benchmark.sh
./scripts/perf-startup-hotspots.sh
```
Benchmark script behavior:
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
- Prints median/min/max/COV in one line.
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
Hotspot script behavior:
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
- Writes raw `perf.data` path for deeper follow-up if needed.
## Run on a Real Android Phone (USB)
1) On phone, enable **Developer options** + **USB debugging**.
2) Connect by USB and accept the debugging trust prompt on phone.
3) Verify ADB can see the device:
```bash
adb devices -l
```
4) Install + launch debug build:
```bash
pnpm android:install
pnpm android:run
```
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
### USB-only gateway testing (no LAN dependency)
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
Terminal A (gateway):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
Terminal B (USB tunnel):
```bash
adb reverse tcp:18789 tcp:18789
```
Then in app **Connect → Manual**:
- Host: `127.0.0.1`
- Port: `18789`
- TLS: off
## Hot Reload / Fast Iteration
This app is native Kotlin + Jetpack Compose.
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
## Connect / Pair
1) Start the gateway (on your master” machine):
1) Start the gateway (on your main machine):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
2) In the Android app:
- Open **Settings**
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode to connect.
3) Approve pairing (on the gateway machine):
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
openclaw devices list
openclaw devices approve <requestId>
```
More details: `docs/platforms/android.md`.
@@ -49,3 +171,58 @@ More details: `docs/platforms/android.md`.
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
Pre-req checklist:
1) Gateway is running and reachable from the Android app.
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
3) App stays unlocked and in foreground for the whole run.
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
6) No interactive system dialogs should be pending before test start.
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
8) Local operator test client pairing is approved. If first run fails with `pairing required`, approve latest pending device pairing request, then rerun:
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
```bash
openclaw devices list
openclaw devices approve --latest
```
Run:
```bash
pnpm android:test:integration
```
Optional overrides:
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
Common failure quick-fixes:
- `pairing required` before tests start:
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
## Contributions
This Android app is currently being rebuilt.
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.

View File

@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,150 +1,168 @@
import com.android.build.api.variant.impl.VariantOutputImpl
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.android.application")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "ai.openclaw.android"
compileSdk = 36
namespace = "ai.openclaw.android"
compileSdk = 36
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
}
}
}
defaultConfig {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602210
versionName = "2026.2.21"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
defaultConfig {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202603010
versionName = "2026.3.2"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
debug {
isMinifyEnabled = false
buildFeatures {
compose = true
buildConfig = true
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
)
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
lint {
disable += setOf(
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
)
}
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "openclaw-${versionName}-${buildType}.apk"
output.outputFileName = outputFileName
}
}
val outputFileName = "openclaw-$versionName-$buildType.apk"
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
implementation(composeBom)
androidTestImplementation(composeBom)
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.6")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.7")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
implementation("org.commonmark:commonmark:0.27.1")
implementation("org.commonmark:commonmark-ext-autolink:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1")
implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
useJUnitPlatform()
}

View File

@@ -15,6 +15,16 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.camera"
@@ -38,6 +48,15 @@
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -50,6 +69,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,20 +1,13 @@
package ai.openclaw.android
import android.Manifest
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.os.Build
import android.view.WindowManager
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.view.WindowCompat
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -29,12 +22,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
WebView.setWebContentsDebuggingEnabled(isDebuggable)
applyImmersiveMode()
requestDiscoveryPermissionsIfNeeded()
requestNotificationPermissionIfNeeded()
NodeForegroundService.start(this)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
@@ -62,18 +50,9 @@ class MainActivity : ComponentActivity() {
}
}
}
}
override fun onResume() {
super.onResume()
applyImmersiveMode()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
}
// Keep startup path lean: start foreground service after first frame.
window.decorView.post { NodeForegroundService.start(this) }
}
override fun onStart() {
@@ -85,46 +64,4 @@ class MainActivity : ComponentActivity() {
viewModel.setForeground(false)
super.onStop()
}
private fun applyImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
private fun requestDiscoveryPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= 33) {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
}
} else {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < 33) return
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
}
}
}

View File

@@ -8,12 +8,17 @@ import ai.openclaw.android.node.CameraCaptureManager
import ai.openclaw.android.node.CanvasController
import ai.openclaw.android.node.ScreenRecordManager
import ai.openclaw.android.node.SmsManager
import ai.openclaw.android.voice.VoiceConversationEntry
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val runtime: NodeRuntime = (app as NodeApp).runtime
val canvas: CanvasController = runtime.canvas
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
@@ -22,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
val isConnected: StateFlow<Boolean> = runtime.isConnected
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
@@ -40,19 +46,22 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
val talkStatusText: StateFlow<String> = runtime.talkStatusText
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
val micStatusText: StateFlow<String> = runtime.micStatusText
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@@ -110,24 +119,28 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
fun setOnboardingCompleted(value: Boolean) {
runtime.setOnboardingCompleted(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
fun setVoiceScreenActive(active: Boolean) {
runtime.setVoiceScreenActive(active)
}
fun resetWakeWordsDefaults() {
runtime.resetWakeWordsDefaults()
fun setMicEnabled(enabled: Boolean) {
runtime.setMicEnabled(enabled)
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
runtime.setVoiceWakeMode(mode)
}
fun setTalkEnabled(enabled: Boolean) {
runtime.setTalkEnabled(enabled)
fun setSpeakerEnabled(enabled: Boolean) {
runtime.setSpeakerEnabled(enabled)
}
fun refreshGatewayConnection() {
@@ -158,6 +171,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun requestCanvasRehydrate(source: String = "screen_tab") {
runtime.requestCanvasRehydrate(source = source, force = true)
}
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
}

View File

@@ -2,23 +2,12 @@ package ai.openclaw.android
import android.app.Application
import android.os.StrictMode
import android.util.Log
import java.security.Security
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
// Register Bouncy Castle as highest-priority provider for Ed25519 support
try {
val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
.getDeclaredConstructor().newInstance() as java.security.Provider
Security.removeProvider("BC")
Security.insertProviderAt(bcProvider, 1)
} catch (it: Throwable) {
Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
}
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()

View File

@@ -39,22 +39,22 @@ class NodeForegroundService : Service() {
runtime.statusText,
runtime.serverName,
runtime.isConnected,
runtime.voiceWakeMode,
runtime.voiceWakeIsListening,
) { status, server, connected, voiceMode, voiceListening ->
Quint(status, server, connected, voiceMode, voiceListening)
}.collect { (status, server, connected, voiceMode, voiceListening) ->
runtime.micEnabled,
runtime.micIsListening,
) { status, server, connected, micEnabled, micListening ->
Quint(status, server, connected, micEnabled, micListening)
}.collect { (status, server, connected, micEnabled, micListening) ->
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
val voiceSuffix =
if (voiceMode == VoiceWakeMode.Always) {
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
val micSuffix =
if (micEnabled) {
if (micListening) " · Mic: Listening" else " · Mic: Pending"
} else {
""
}
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
val requiresMic =
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
micEnabled && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,

View File

@@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import ai.openclaw.android.chat.ChatController
import ai.openclaw.android.chat.ChatMessage
@@ -18,8 +19,9 @@ import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
import ai.openclaw.android.node.*
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.android.voice.MicCaptureManager
import ai.openclaw.android.voice.TalkModeManager
import ai.openclaw.android.voice.VoiceWakeManager
import ai.openclaw.android.voice.VoiceConversationEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -36,6 +38,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(context: Context) {
@@ -53,40 +56,6 @@ class NodeRuntime(context: Context) {
private val externalAudioCaptureActive = MutableStateFlow(false)
private val voiceWake: VoiceWakeManager by lazy {
VoiceWakeManager(
context = appContext,
scope = scope,
onCommand = { command ->
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
)
},
)
}
val voiceWakeIsListening: StateFlow<Boolean>
get() = voiceWake.isListening
val voiceWakeStatusText: StateFlow<String>
get() = voiceWake.statusText
val talkStatusText: StateFlow<String>
get() = talkMode.statusText
val talkIsListening: StateFlow<Boolean>
get() = talkMode.isListening
val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
val discoveryStatusText: StateFlow<String> = discovery.statusText
@@ -97,8 +66,6 @@ class NodeRuntime(context: Context) {
private val cameraHandler: CameraHandler = CameraHandler(
appContext = appContext,
camera = camera,
prefs = prefs,
connectedEndpoint = { connectedEndpoint },
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
@@ -124,6 +91,34 @@ class NodeRuntime(context: Context) {
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
private val systemHandler: SystemHandler = SystemHandler(
appContext = appContext,
)
private val photosHandler: PhotosHandler = PhotosHandler(
appContext = appContext,
)
private val contactsHandler: ContactsHandler = ContactsHandler(
appContext = appContext,
)
private val calendarHandler: CalendarHandler = CalendarHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
@@ -145,7 +140,9 @@ class NodeRuntime(context: Context) {
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { voiceWakeMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
@@ -155,6 +152,13 @@ class NodeRuntime(context: Context) {
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
systemHandler = systemHandler,
photosHandler = photosHandler,
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
@@ -163,10 +167,19 @@ class NodeRuntime(context: Context) {
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
private lateinit var gatewayEventHandler: GatewayEventHandler
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
@@ -174,6 +187,8 @@ class NodeRuntime(context: Context) {
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -194,6 +209,13 @@ class NodeRuntime(context: Context) {
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _canvasA2uiHydrated = MutableStateFlow(false)
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
private val _canvasRehydratePending = MutableStateFlow(false)
val canvasRehydratePending: StateFlow<Boolean> = _canvasRehydratePending.asStateFlow()
private val _canvasRehydrateErrorText = MutableStateFlow<String?>(null)
val canvasRehydrateErrorText: StateFlow<String?> = _canvasRehydrateErrorText.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
@@ -207,8 +229,9 @@ class NodeRuntime(context: Context) {
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private var didAutoRequestCanvasRehydrate = false
private val canvasRehydrateSeq = AtomicLong(0)
private var operatorConnected = false
private var nodeConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
@@ -225,8 +248,13 @@ class NodeRuntime(context: Context) {
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
updateStatus()
scope.launch { refreshBrandingFromGateway() }
scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
micCapture.onGatewayConnectionChanged(true)
scope.launch {
refreshBrandingFromGateway()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
}
}
},
onDisconnected = { message ->
operatorConnected = false
@@ -237,11 +265,10 @@ class NodeRuntime(context: Context) {
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
micCapture.onGatewayConnectionChanged(false)
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
@@ -254,14 +281,22 @@ class NodeRuntime(context: Context) {
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
nodeConnected = true
_nodeConnected.value = true
nodeStatusText = "Connected"
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message ->
nodeConnected = false
_nodeConnected.value = false
nodeStatusText = message
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
showLocalCanvasOnDisconnect()
},
@@ -274,6 +309,14 @@ class NodeRuntime(context: Context) {
},
)
init {
DeviceNotificationListenerService.setNodeEventSink { event, payloadJson ->
scope.launch {
nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson)
}
}
}
private val chat: ChatController =
ChatController(
scope = scope,
@@ -281,13 +324,86 @@ class NodeRuntime(context: Context) {
json = json,
supportsChatSubscribe = false,
)
private val talkMode: TalkModeManager by lazy {
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
).also { speaker ->
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
}
}
private val voiceReplySpeaker: TalkModeManager
get() = voiceReplySpeakerLazy.value
private val micCapture: MicCaptureManager by lazy {
MicCaptureManager(
context = appContext,
scope = scope,
sendToGateway = { message, onRunIdKnown ->
val idempotencyKey = UUID.randomUUID().toString()
// Notify MicCaptureManager of the idempotency key *before* the network
// call so pendingRunId is set before any chat events can arrive.
onRunIdKnown(idempotencyKey)
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("message", JsonPrimitive(message))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
parseChatSendRunId(response) ?: idempotencyKey
},
speakAssistantReply = { text ->
// Skip if TalkModeManager is handling TTS (ttsOnAllResponses) to avoid
// double-speaking the same assistant reply from both pipelines.
if (!talkMode.ttsOnAllResponses) {
voiceReplySpeaker.speakAssistantReply(text)
}
},
)
}
val micStatusText: StateFlow<String>
get() = micCapture.statusText
val micLiveTranscript: StateFlow<String?>
get() = micCapture.liveTranscript
val micIsListening: StateFlow<Boolean>
get() = micCapture.isListening
val micEnabled: StateFlow<Boolean>
get() = micCapture.micEnabled
val micCooldown: StateFlow<Boolean>
get() = micCapture.micCooldown
val micQueuedMessages: StateFlow<List<String>>
get() = micCapture.queuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>>
get() = micCapture.conversation
val micInputLevel: StateFlow<Float>
get() = micCapture.inputLevel
val micIsSending: StateFlow<Boolean>
get() = micCapture.isSending
private val talkMode: TalkModeManager by lazy {
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = true,
isConnected = { operatorConnected },
)
}
@@ -302,13 +418,20 @@ class NodeRuntime(context: Context) {
private fun updateStatus() {
_isConnected.value = operatorConnected
val operator = operatorStatusText.trim()
val node = nodeStatusText.trim()
_statusText.value =
when {
operatorConnected && nodeConnected -> "Connected"
operatorConnected && !nodeConnected -> "Connected (node offline)"
!operatorConnected && nodeConnected -> "Connected (operator offline)"
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
else -> nodeStatusText
operatorConnected && _nodeConnected.value -> "Connected"
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
!operatorConnected && _nodeConnected.value ->
if (operator.isNotEmpty() && operator != "Offline") {
"Connected (operator: $operator)"
} else {
"Connected (operator offline)"
}
operator.isNotBlank() && operator != "Offline" -> operator
else -> node
}
}
@@ -328,24 +451,78 @@ class NodeRuntime(context: Context) {
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
canvas.navigate("")
}
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
scope.launch {
if (!_nodeConnected.value) {
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "Node offline. Reconnect and retry."
return@launch
}
if (!force && didAutoRequestCanvasRehydrate) return@launch
didAutoRequestCanvasRehydrate = true
val requestId = canvasRehydrateSeq.incrementAndGet()
_canvasRehydratePending.value = true
_canvasRehydrateErrorText.value = null
val sessionKey = resolveMainSessionKey()
val prompt =
"Restore canvas now for session=$sessionKey source=$source. " +
"If existing A2UI state exists, replay it immediately. " +
"If not, create and render a compact mobile-friendly dashboard in Canvas."
val sent =
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(prompt))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
}.toString(),
)
if (!sent) {
if (!force) {
didAutoRequestCanvasRehydrate = false
}
if (canvasRehydrateSeq.get() == requestId) {
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry."
}
Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable")
return@launch
}
scope.launch {
delay(20_000)
if (canvasRehydrateSeq.get() != requestId) return@launch
if (!_canvasRehydratePending.value) return@launch
if (_canvasA2uiHydrated.value) return@launch
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry."
}
}
}
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@@ -363,50 +540,24 @@ class NodeRuntime(context: Context) {
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
init {
gatewayEventHandler = GatewayEventHandler(
scope = scope,
prefs = prefs,
json = json,
operatorSession = operatorSession,
isConnected = { _isConnected.value },
)
scope.launch {
combine(
voiceWakeMode,
isForeground,
externalAudioCaptureActive,
wakeWords,
) { mode, foreground, externalAudio, words ->
Quad(mode, foreground, externalAudio, words)
}.distinctUntilChanged()
.collect { (mode, foreground, externalAudio, words) ->
voiceWake.setTriggerWords(words)
val shouldListen =
when (mode) {
VoiceWakeMode.Off -> false
VoiceWakeMode.Foreground -> foreground
VoiceWakeMode.Always -> true
} && !externalAudio
if (!shouldListen) {
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
return@collect
}
if (!hasRecordAudioPermission()) {
voiceWake.stop(statusText = "Microphone permission required")
return@collect
}
voiceWake.start()
}
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
}
scope.launch {
talkEnabled.collect { enabled ->
talkMode.setEnabled(enabled)
prefs.loadGatewayToken()
}
scope.launch {
prefs.talkEnabled.collect { enabled ->
// MicCaptureManager handles STT + send to gateway.
// TalkModeManager plays TTS on assistant responses.
micCapture.setMicEnabled(enabled)
if (enabled) {
// Mic on = user is on voice screen and wants TTS responses.
talkMode.ttsOnAllResponses = true
scope.launch { talkMode.ensureChatSubscribed() }
}
externalAudioCaptureActive.value = enabled
}
}
@@ -514,25 +665,49 @@ class NodeRuntime(context: Context) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
// User left voice screen — stop mic and TTS
talkMode.ttsOnAllResponses = false
talkMode.stopTts()
micCapture.setMicEnabled(false)
prefs.setTalkEnabled(false)
}
// Don't re-enable on active=true; mic toggle drives that
}
fun resetWakeWordsDefaults() {
setWakeWords(SecurePrefs.defaultWakeWords)
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.setVoiceWakeMode(mode)
}
fun setTalkEnabled(value: Boolean) {
fun setMicEnabled(value: Boolean) {
prefs.setTalkEnabled(value)
if (value) {
// Tapping mic on interrupts any active TTS (barge-in)
talkMode.stopTts()
talkMode.ttsOnAllResponses = true
scope.launch { talkMode.ensureChatSubscribed() }
}
micCapture.setMicEnabled(value)
externalAudioCaptureActive.value = value
}
val speakerEnabled: StateFlow<Boolean>
get() = prefs.speakerEnabled
fun setSpeakerEnabled(value: Boolean) {
prefs.setSpeakerEnabled(value)
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.setPlaybackEnabled(value)
}
// Keep TalkMode in sync so speaker mute works when ttsOnAllResponses is active.
talkMode.setPlaybackEnabled(value)
}
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return
val endpoint =
connectedEndpoint ?: run {
_statusText.value = "Failed: no cached gateway endpoint"
return
}
operatorStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
@@ -639,10 +814,10 @@ class NodeRuntime(context: Context) {
contextJson = contextJson,
)
val connected = nodeConnected
val connected = _nodeConnected.value
var error: String? = null
if (connected) {
try {
val sent =
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
@@ -654,8 +829,8 @@ class NodeRuntime(context: Context) {
put("key", JsonPrimitive(actionId))
}.toString(),
)
} catch (e: Throwable) {
error = e.message ?: "send failed"
if (!sent) {
error = "send failed"
}
} else {
error = "gateway not connected"
@@ -705,15 +880,20 @@ class NodeRuntime(context: Context) {
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event == "voicewake.changed") {
gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
return
}
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
}
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {

View File

@@ -20,19 +20,21 @@ class SecurePrefs(context: Context) {
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val masterKey =
MasterKey.Builder(context)
private val masterKey by lazy {
MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences by lazy {
createPrefs(appContext, "openclaw.node.secure")
}
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
@@ -41,48 +43,51 @@ class SecurePrefs(context: Context) {
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled =
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost =
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow<String> = _manualHost
private val _manualPort =
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _gatewayToken =
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow<String> = _gatewayToken
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
@@ -91,119 +96,137 @@ class SecurePrefs(context: Context) {
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit { putString(displayNameKey, trimmed) }
plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit { putBoolean("camera.enabled", value) }
plainPrefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
prefs.edit { putString("location.enabledMode", mode.rawValue) }
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.edit { putBoolean("location.preciseEnabled", value) }
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
prefs.edit { putBoolean("screen.preventSleep", value) }
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.enabled", value) }
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit { putString("gateway.manual.host", trimmed) }
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit { putInt("gateway.manual.port", value) }
plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.tls", value) }
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setGatewayToken(value: String) {
prefs.edit { putString("gateway.manual.token", value) }
_gatewayToken.value = value
val trimmed = value.trim()
securePrefs.edit { putString("gateway.manual.token", trimmed) }
_gatewayToken.value = trimmed
}
fun setGatewayPassword(value: String) {
saveGatewayPassword(value)
}
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadGatewayToken(): String? {
val manual = _gatewayToken.value.trim()
val manual =
_gatewayToken.value.trim().ifEmpty {
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
if (stored.isNotEmpty()) _gatewayToken.value = stored
stored
}
if (manual.isNotEmpty()) return manual
val key = "gateway.token.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim()
val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) }
securePrefs.edit { putString(key, token.trim()) }
}
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim()
val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) }
securePrefs.edit { putString(key, password.trim()) }
}
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) }
plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return prefs.getString(key, null)
return securePrefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
securePrefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
securePrefs.edit { remove(key) }
}
private fun createPrefs(context: Context, name: String): SharedPreferences {
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
name,
@@ -214,21 +237,21 @@ class SecurePrefs(context: Context) {
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit { putString("node.instanceId", fresh) }
plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
prefs.edit { putString(displayNameKey, resolved) }
plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -236,34 +259,39 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit { putString("voiceWake.triggerWords", encoded) }
plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
fun setTalkEnabled(value: Boolean) {
prefs.edit { putBoolean("talk.enabled", value) }
plainPrefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
fun setSpeakerEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
_speakerEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = prefs.getString(voiceWakeModeKey, null)
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List<String> {
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords
return try {
val element = json.parseToJsonElement(raw)
@@ -281,5 +309,4 @@ class SecurePrefs(context: Context) {
defaultWakeWords
}
}
}

View File

@@ -261,11 +261,7 @@ class ChatController(
val key = _sessionKey.value
try {
if (supportsChatSubscribe) {
try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) {
// best-effort
}
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
}
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
@@ -315,16 +311,19 @@ class ChatController(
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
val runId = payload["runId"].asStringOrNull()
if (runId != null) {
val isPending =
synchronized(pendingRuns) {
pendingRuns.contains(runId)
}
if (!isPending) return
}
val isPending =
if (runId != null) synchronized(pendingRuns) { pendingRuns.contains(runId) } else true
val state = payload["state"].asStringOrNull()
when (state) {
"delta" -> {
// Only show streaming text for runs we initiated
if (!isPending) return
val text = parseAssistantDeltaText(payload)
if (!text.isNullOrEmpty()) {
_streamingAssistantText.value = text
}
}
"final", "aborted", "error" -> {
if (state == "error") {
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
@@ -351,9 +350,8 @@ class ChatController(
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val runId = payload["runId"].asStringOrNull()
val sessionId = _sessionId.value
if (sessionId != null && runId != sessionId) return
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
val stream = payload["stream"].asStringOrNull()
val data = payload["data"].asObjectOrNull()
@@ -398,6 +396,21 @@ class ChatController(
}
}
private fun parseAssistantDeltaText(payload: JsonObject): String? {
val message = payload["message"].asObjectOrNull() ?: return null
if (message["role"].asStringOrNull() != "assistant") return null
val content = message["content"].asArrayOrNull() ?: return null
for (item in content) {
val obj = item.asObjectOrNull() ?: continue
if (obj["type"].asStringOrNull() != "text") continue
val text = obj["text"].asStringOrNull()
if (!text.isNullOrEmpty()) {
return text
}
}
return null
}
private fun publishPendingToolCalls() {
_pendingToolCalls.value =
pendingToolCallsById.values.sortedBy { it.startedAtMs }

View File

@@ -0,0 +1,52 @@
package ai.openclaw.android.gateway
internal object DeviceAuthPayload {
fun buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeMetadataField(platform)
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
return listOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
).joinToString("|")
}
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
val out = StringBuilder(trimmed.length)
for (ch in trimmed) {
if (ch in 'A'..'Z') {
out.append((ch.code + 32).toChar())
} else {
out.append(ch)
}
}
return out.toString()
}
}

View File

@@ -2,13 +2,18 @@ package ai.openclaw.android.gateway
import ai.openclaw.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
interface DeviceAuthTokenStore {
fun loadToken(deviceId: String, role: String): String?
fun saveToken(deviceId: String, role: String, token: String)
}
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
override fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
override fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}

View File

@@ -3,11 +3,7 @@ package ai.openclaw.android.gateway
import android.content.Context
import android.util.Base64
import java.io.File
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -22,21 +18,26 @@ data class DeviceIdentity(
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
fun loadOrCreate(): DeviceIdentity {
cachedIdentity?.let { return it }
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
cachedIdentity = updated
return updated
}
cachedIdentity = existing
return existing
}
val fresh = generate()
save(fresh)
cachedIdentity = fresh
return fresh
}
@@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) {
}
}
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
) {
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
}
return spki
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
val out = CharArray(digest.size * 2)
var i = 0
for (byte in digest) {
out.append(String.format("%02x", byte))
val v = byte.toInt() and 0xff
out[i++] = HEX[v ushr 4]
out[i++] = HEX[v and 0x0f]
}
return out.toString()
return String(out)
}
private fun base64UrlEncode(data: ByteArray): String {
@@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) {
}
companion object {
private val ED25519_SPKI_PREFIX =
byteArrayOf(
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
)
private val HEX = "0123456789abcdef".toCharArray()
}
}

View File

@@ -55,13 +55,18 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val deviceAuthStore: DeviceAuthTokenStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
private companion object {
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
}
data class InvokeRequest(
val id: String,
val nodeId: String,
@@ -131,8 +136,8 @@ class GatewaySession(
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
@@ -147,8 +152,10 @@ class GatewaySession(
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
return true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
return false
}
}
@@ -166,6 +173,47 @@ class GatewaySession(
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
val conn = currentConnection ?: return false
val response =
try {
conn.request(
"node.canvas.capability.refresh",
params = buildJsonObject {},
timeoutMs = timeoutMs,
)
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh failed: ${err.message ?: err::class.java.simpleName}")
return false
}
if (!response.ok) {
val err = response.error
Log.w(
"OpenClawGateway",
"node.canvas.capability.refresh rejected: ${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}",
)
return false
}
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
if (refreshedCapability.isEmpty()) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
return false
}
val scopedCanvasHostUrl = canvasHostUrl?.trim().orEmpty()
if (scopedCanvasHostUrl.isEmpty()) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing local canvasHostUrl")
return false
}
val refreshedUrl = replaceCanvasCapabilityInScopedHostUrl(scopedCanvasHostUrl, refreshedCapability)
if (refreshedUrl == null) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh unable to rewrite scoped canvas URL")
return false
}
canvasHostUrl = refreshedUrl
return true
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private inner class Connection(
@@ -193,9 +241,7 @@ class GatewaySession(
suspend fun connect() {
val scheme = if (tls != null) "wss" else "ws"
val url = "$scheme://${endpoint.host}:${endpoint.port}"
val httpScheme = if (tls != null) "https" else "http"
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
val request = Request.Builder().url(url).header("Origin", origin).build()
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, Listener())
try {
connectDeferred.await()
@@ -300,17 +346,19 @@ class GatewaySession(
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000)
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg)
}
handleConnectSuccess(res, identity.deviceId)
connectDeferred.complete(Unit)
}
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
@@ -318,16 +366,15 @@ class GatewaySession(
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
val sessionDefaults =
obj["snapshot"].asObjectOrNull()
?.get("sessionDefaults").asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
connectDeferred.complete(Unit)
}
private fun buildConnectParams(
@@ -366,7 +413,7 @@ class GatewaySession(
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayload(
DeviceAuthPayload.buildV3(
deviceId = identity.deviceId,
clientId = client.id,
clientMode = client.mode,
@@ -375,6 +422,8 @@ class GatewaySession(
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
platform = client.platform,
deviceFamily = client.deviceFamily,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -493,11 +542,16 @@ class GatewaySession(
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
sendInvokeResult(id, nodeId, result)
sendInvokeResult(id, nodeId, result, timeoutMs)
}
}
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
private suspend fun sendInvokeResult(
id: String,
nodeId: String,
result: InvokeResult,
invokeTimeoutMs: Long?,
) {
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
@@ -519,24 +573,20 @@ class GatewaySession(
)
}
}
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
try {
request("node.invoke.result", params, timeoutMs = 15_000)
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
} catch (err: Throwable) {
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
Log.w(
loggerTag,
"node.invoke.result failed (ackTimeoutMs=$ackTimeoutMs): ${err.message ?: err::class.java.simpleName}",
)
}
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
return InvokeResult.error(code = parsed.code, message = parsed.message)
}
private fun failPending() {
@@ -584,51 +634,30 @@ class GatewaySession(
}
}
private fun buildDeviceAuthPayload(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val parts =
mutableListOf(
"v2",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
)
return parts.joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
private fun normalizeCanvasHostUrl(
raw: String?,
endpoint: GatewayEndpoint,
isTlsConnection: Boolean,
): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
val suffix = buildUrlSuffix(parsed)
// Detect TLS reverse proxy: endpoint on port 443, or domain-based host
val tls = endpoint.port == 443 || endpoint.host.contains(".")
// If raw URL is a non-loopback address AND we're behind TLS reverse proxy,
// fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy)
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
if (tls && port > 0 && port != 443) {
// Rewrite the URL to use the reverse proxy port instead of the raw gateway port
val fixedScheme = "https"
val formattedHost = if (host.contains(":")) "[${host}]" else host
return "$fixedScheme://$formattedHost"
// If raw URL is a non-loopback address and this connection uses TLS,
// normalize scheme/port to the endpoint we actually connected to.
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
val needsTlsRewrite =
isTlsConnection &&
(
!scheme.equals("https", ignoreCase = true) ||
(port > 0 && port != endpoint.port) ||
(port <= 0 && endpoint.port != 443)
)
if (needsTlsRewrite) {
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
}
return trimmed
}
@@ -639,14 +668,26 @@ class GatewaySession(
?: endpoint.host.trim()
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
// When connecting through a reverse proxy (TLS on standard port), use the
// connection endpoint's scheme and port instead of the raw canvas port.
val fallbackScheme = if (tls) "https" else scheme
// Behind reverse proxy, always use the proxy port (443), not the raw canvas port
val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort"
return "$fallbackScheme://$formattedHost$portSuffix"
// For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata.
val fallbackScheme = if (isTlsConnection) "https" else scheme
// For TLS, always use the connected endpoint port.
val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
}
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
val loweredScheme = scheme.lowercase()
val formattedHost = if (host.contains(":")) "[${host}]" else host
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
return "$loweredScheme://$formattedHost$portSuffix$suffix"
}
private fun buildUrlSuffix(uri: java.net.URI?): String {
if (uri == null) return ""
val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: ""
val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: ""
return "$path$query$fragment"
}
private fun isLoopbackHost(raw: String?): Boolean {
@@ -696,3 +737,24 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
null
}
}
internal fun replaceCanvasCapabilityInScopedHostUrl(
scopedUrl: String,
capability: String,
): String? {
val marker = "/__openclaw__/cap/"
val markerStart = scopedUrl.indexOf(marker)
if (markerStart < 0) return null
val capabilityStart = markerStart + marker.length
val slashEnd = scopedUrl.indexOf("/", capabilityStart).takeIf { it >= 0 }
val queryEnd = scopedUrl.indexOf("?", capabilityStart).takeIf { it >= 0 }
val fragmentEnd = scopedUrl.indexOf("#", capabilityStart).takeIf { it >= 0 }
val capabilityEnd = listOfNotNull(slashEnd, queryEnd, fragmentEnd).minOrNull() ?: scopedUrl.length
if (capabilityEnd <= capabilityStart) return null
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)
}
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
return normalized.coerceIn(15_000L, 120_000L)
}

View File

@@ -0,0 +1,39 @@
package ai.openclaw.android.gateway
data class ParsedInvokeError(
val code: String,
val message: String,
val hadExplicitCode: Boolean,
) {
val prefixedMessage: String
get() = "$code: $message"
}
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
}
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return ParsedInvokeError(
code = code,
message = rest.ifEmpty { trimmed },
hadExplicitCode = true,
)
}
}
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
}
fun parseInvokeErrorFromThrowable(
err: Throwable,
fallbackMessage: String = "error",
): ParsedInvokeError {
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
return parseInvokeErrorMessage(raw)
}

View File

@@ -0,0 +1,384 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val DEFAULT_CALENDAR_LIMIT = 50
internal data class CalendarEventsRequest(
val startMs: Long,
val endMs: Long,
val limit: Int,
)
internal data class CalendarAddRequest(
val title: String,
val startMs: Long,
val endMs: Long,
val isAllDay: Boolean,
val location: String?,
val notes: String?,
val calendarId: Long?,
val calendarTitle: String?,
)
internal data class CalendarEventRecord(
val identifier: String,
val title: String,
val startISO: String,
val endISO: String,
val isAllDay: Boolean,
val location: String?,
val calendarTitle: String?,
)
internal interface CalendarDataSource {
fun hasReadPermission(context: Context): Boolean
fun hasWritePermission(context: Context): Boolean
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
}
private object SystemCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, request.startMs)
ContentUris.appendId(builder, request.endMs)
val projection =
arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
)
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
if (cursor == null) return emptyList()
val out = mutableListOf<CalendarEventRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val id = cursor.getLong(0)
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
val beginMs = cursor.getLong(2)
val endMs = cursor.getLong(3)
val isAllDay = cursor.getInt(4) == 1
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
out +=
CalendarEventRecord(
identifier = id.toString(),
title = title,
startISO = Instant.ofEpochMilli(beginMs).toString(),
endISO = Instant.ofEpochMilli(endMs).toString(),
isAllDay = isAllDay,
location = location,
calendarTitle = calendarTitle,
)
}
return out
}
}
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
val resolver = context.contentResolver
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
val values =
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
put(CalendarContract.Events.TITLE, request.title)
put(CalendarContract.Events.DTSTART, request.startMs)
put(CalendarContract.Events.DTEND, request.endMs)
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId = uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
return loadEventById(resolver, eventId)
?: throw IllegalStateException("calendar insert failed")
}
private fun resolveCalendarId(
resolver: ContentResolver,
calendarId: Long?,
calendarTitle: String?,
): Long {
if (calendarId != null) {
if (calendarExists(resolver, calendarId)) return calendarId
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
}
if (!calendarTitle.isNullOrEmpty()) {
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
}
findDefaultCalendarId(resolver)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
}
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
}
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun loadEventById(
resolver: ContentResolver,
eventId: Long,
): CalendarEventRecord? {
val projection =
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
)
resolver.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
}
}
class CalendarHandler private constructor(
private val appContext: Context,
private val dataSource: CalendarDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseEventsRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val events = dataSource.events(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"events",
buildJsonArray { events.forEach { add(eventJson(it)) } },
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
)
}
}
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseAddRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
if (request.title.isEmpty()) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: title required",
)
}
if (request.endMs <= request.startMs) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: endISO must be after startISO",
)
}
return try {
val event = dataSource.add(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put("event", eventJson(event))
}.toString(),
)
} catch (err: IllegalArgumentException) {
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
GatewaySession.InvokeResult.error(code = code, message = msg)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
)
}
}
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
if (paramsJson.isNullOrBlank()) {
val start = Instant.now()
val end = start.plus(7, ChronoUnit.DAYS)
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
val resolvedStart = start ?: Instant.now()
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
return CalendarEventsRequest(
startMs = resolvedStart.toEpochMilli(),
endMs = resolvedEnd.toEpochMilli(),
limit = limit,
)
}
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
return CalendarAddRequest(
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
startMs = start.toEpochMilli(),
endMs = end.toEpochMilli(),
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
)
}
private fun parseISO(raw: String?): Instant? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
return try {
Instant.parse(value)
} catch (_: Throwable) {
null
}
}
private fun eventJson(event: CalendarEventRecord): JsonObject {
return buildJsonObject {
put("identifier", JsonPrimitive(event.identifier))
put("title", JsonPrimitive(event.title))
put("startISO", JsonPrimitive(event.startISO))
put("endISO", JsonPrimitive(event.endISO))
put("isAllDay", JsonPrimitive(event.isAllDay))
event.location?.let { put("location", JsonPrimitive(it)) }
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CalendarDataSource,
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -1,13 +1,16 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.Context
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.util.Base64
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.util.Base64
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.CameraInfo
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
@@ -30,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
@@ -40,6 +44,12 @@ import kotlin.coroutines.resumeWithException
class CameraCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
data class CameraDeviceInfo(
val id: String,
val name: String,
val position: String,
val deviceType: String,
)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
@Volatile private var permissionRequester: PermissionRequester? = null
@@ -52,6 +62,14 @@ class CameraCaptureManager(private val context: Context) {
permissionRequester = requester
}
suspend fun listDevices(): List<CameraDeviceInfo> =
withContext(Dispatchers.Main) {
val provider = context.cameraProvider()
provider.availableCameraInfos
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
.sortedBy { it.id }
}
private suspend fun ensureCameraPermission() {
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (granted) return
@@ -80,14 +98,15 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(paramsJson) ?: 800
val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(params) ?: 1600
val deviceId = parseDeviceId(params)
val provider = context.cameraProvider()
val capture = ImageCapture.Builder().build()
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
val selector = resolveCameraSelector(provider, facing, deviceId)
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
@@ -145,12 +164,14 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(paramsJson) ?: true
val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
val deviceId = parseDeviceId(params)
if (includeAudio) ensureMicPermission()
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio")
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
val provider = context.cameraProvider()
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
@@ -162,8 +183,7 @@ class CameraCaptureManager(private val context: Context) {
)
.build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
val selector = resolveCameraSelector(provider, facing, deviceId)
// CameraX requires a Preview use case for the camera to start producing frames;
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
@@ -270,49 +290,84 @@ class CameraCaptureManager(private val context: Context) {
return rotated
}
private fun parseFacing(paramsJson: String?): String? =
when {
paramsJson?.contains("\"front\"") == true -> "front"
paramsJson?.contains("\"back\"") == true -> "back"
else -> null
}
private fun parseQuality(paramsJson: String?): Double? =
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
private fun parseMaxWidth(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
private fun parseDurationMs(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
val raw = paramsJson ?: return null
val key = "\"includeAudio\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return when {
tail.startsWith("true") -> true
tail.startsWith("false") -> false
private fun parseFacing(params: JsonObject?): String? {
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
return when (value) {
"front", "back" -> value
else -> null
}
}
private fun parseNumber(paramsJson: String?, key: String): String? {
val raw = paramsJson ?: return null
val needle = "\"$key\""
val idx = raw.indexOf(needle)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + needle.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return tail.takeWhile { it.isDigit() || it == '.' }
}
private fun parseQuality(params: JsonObject?): Double? =
parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
parseJsonString(params, "deviceId")
?.trim()
?.takeIf { it.isNotEmpty() }
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
private fun resolveCameraSelector(
provider: ProcessCameraProvider,
facing: String,
deviceId: String?,
): CameraSelector {
if (deviceId.isNullOrEmpty()) {
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
}
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
if (!availableIds.contains(deviceId)) {
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
}
return CameraSelector.Builder()
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
val cameraId = cameraIdOrNull(info) ?: return null
val lensFacing =
runCatching {
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
}.getOrNull()
val position =
when (lensFacing) {
CameraCharacteristics.LENS_FACING_FRONT -> "front"
CameraCharacteristics.LENS_FACING_BACK -> "back"
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
else -> "unspecified"
}
val deviceType =
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
val name =
when (position) {
"front" -> "Front Camera"
"back" -> "Back Camera"
"external" -> "External Camera"
else -> "Camera $cameraId"
}
return CameraDeviceInfo(
id = cameraId,
name = name,
position = position,
deviceType = deviceType,
)
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraIdOrNull(info: CameraInfo): String? =
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
}
private suspend fun Context.cameraProvider(): ProcessCameraProvider =

View File

@@ -3,25 +3,57 @@ package ai.openclaw.android.node
import android.content.Context
import ai.openclaw.android.CameraHudKind
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.SecurePrefs
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
private val prefs: SecurePrefs,
private val connectedEndpoint: () -> GatewayEndpoint?,
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
private val triggerCameraFlash: () -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
return try {
val devices = camera.listDevices()
val payload =
buildJsonObject {
put(
"devices",
buildJsonArray {
devices.forEach { device ->
add(
buildJsonObject {
put("id", JsonPrimitive(device.id))
put("name", JsonPrimitive(device.name))
put("position", JsonPrimitive(device.position))
put("deviceType", JsonPrimitive(device.deviceType))
},
)
}
},
)
}.toString()
GatewaySession.InvokeResult.ok(payload)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
GatewaySession.InvokeResult.error(code = code, message = message)
}
}
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
@@ -69,7 +101,7 @@ class CameraHandler(
clipLogFile?.appendText("[CLIP $ts] $msg\n")
android.util.Log.w("openclaw", "camera.clip: $msg")
}
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
val includeAudio = parseIncludeAudio(paramsJson) ?: true
if (includeAudio) externalAudioCaptureActive.value = true
try {
clipLogFile?.writeText("") // clear
@@ -89,62 +121,28 @@ class CameraHandler(
showCameraHud(message, CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
// Upload file via HTTP instead of base64 through WebSocket
clipLog("uploading via HTTP...")
val uploadUrl = try {
withContext(Dispatchers.IO) {
val ep = connectedEndpoint()
val gatewayHost = if (ep != null) {
val isHttps = ep.tlsEnabled || ep.port == 443
if (!isHttps) {
clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64")
throw Exception("HTTPS required for upload (bearer token protection)")
}
if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}"
} else {
clipLog("error: no gateway endpoint connected, cannot upload")
throw Exception("no gateway endpoint connected")
}
val token = prefs.loadGatewayToken() ?: ""
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
val body = filePayload.file.asRequestBody("video/mp4".toMediaType())
val req = okhttp3.Request.Builder()
.url("$gatewayHost/upload/clip.mp4")
.put(body)
.header("Authorization", "Bearer $token")
.build()
clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4")
val resp = client.newCall(req).execute()
val respBody = resp.body?.string() ?: ""
clipLog("upload response: ${resp.code} $respBody")
filePayload.file.delete()
if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}")
// Parse URL from response
val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody)
urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody")
}
} catch (err: Throwable) {
clipLog("upload failed: ${err.message}, falling back to base64")
// Fallback to base64 if upload fails
val bytes = withContext(Dispatchers.IO) {
val b = filePayload.file.readBytes()
filePayload.file.delete()
b
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
return GatewaySession.InvokeResult.ok(
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
val rawBytes = filePayload.file.length()
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
withContext(Dispatchers.IO) { filePayload.file.delete() }
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(
code = "PAYLOAD_TOO_LARGE",
message =
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
)
}
clipLog("returning URL result: $uploadUrl")
val bytes = withContext(Dispatchers.IO) {
val b = filePayload.file.readBytes()
filePayload.file.delete()
b
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
return GatewaySession.InvokeResult.ok(
"""{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
)
} catch (err: Throwable) {
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
@@ -154,4 +152,24 @@ class CameraHandler(
if (includeAudio) externalAudioCaptureActive.value = false
}
}
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
if (paramsJson.isNullOrBlank()) return null
val root =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val value =
(root["includeAudio"] as? JsonPrimitive)
?.contentOrNull
?.trim()
?.lowercase()
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
}

View File

@@ -10,6 +10,9 @@ import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
@@ -31,6 +34,8 @@ class CanvasController {
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
private val _currentUrl = MutableStateFlow<String?>(null)
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -39,15 +44,30 @@ class CanvasController {
return (q * 100.0).toInt().coerceIn(1, 100)
}
private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap {
if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) {
return this
}
val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1)
return scale(maxWidth, scaledHeight)
}
fun attach(webView: WebView) {
this.webView = webView
reload()
applyDebugStatus()
}
fun detach(webView: WebView) {
if (this.webView === webView) {
this.webView = null
}
}
fun navigate(url: String) {
val trimmed = url.trim()
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
_currentUrl.value = this.url
reload()
}
@@ -136,13 +156,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
} else {
bmp
}
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
@@ -153,13 +167,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
} else {
bmp
}
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
val (compressFormat, compressQuality) =

View File

@@ -7,13 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
import ai.openclaw.android.gateway.GatewayConnectOptions
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.gateway.GatewayTlsParams
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawCapability
import ai.openclaw.android.LocationMode
import ai.openclaw.android.VoiceWakeMode
@@ -22,6 +15,8 @@ class ConnectionManager(
private val cameraEnabled: () -> Boolean,
private val locationMode: () -> LocationMode,
private val voiceWakeMode: () -> VoiceWakeMode,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
@@ -79,47 +74,20 @@ class ConnectionManager(
}
}
fun buildInvokeCommands(): List<String> =
buildList {
add(OpenClawCanvasCommand.Present.rawValue)
add(OpenClawCanvasCommand.Hide.rawValue)
add(OpenClawCanvasCommand.Navigate.rawValue)
add(OpenClawCanvasCommand.Eval.rawValue)
add(OpenClawCanvasCommand.Snapshot.rawValue)
add(OpenClawCanvasA2UICommand.Push.rawValue)
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
add(OpenClawCanvasA2UICommand.Reset.rawValue)
add(OpenClawScreenCommand.Record.rawValue)
if (cameraEnabled()) {
add(OpenClawCameraCommand.Snap.rawValue)
add(OpenClawCameraCommand.Clip.rawValue)
}
if (locationMode() != LocationMode.Off) {
add(OpenClawLocationCommand.Get.rawValue)
}
if (smsAvailable()) {
add(OpenClawSmsCommand.Send.rawValue)
}
if (BuildConfig.DEBUG) {
add("debug.logs")
add("debug.ed25519")
}
add("app.update")
}
private fun runtimeFlags(): NodeRuntimeFlags =
NodeRuntimeFlags(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
debugBuild = BuildConfig.DEBUG,
)
fun buildCapabilities(): List<String> =
buildList {
add(OpenClawCapability.Canvas.rawValue)
add(OpenClawCapability.Screen.rawValue)
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(OpenClawCapability.VoiceWake.rawValue)
}
if (locationMode() != LocationMode.Off) {
add(OpenClawCapability.Location.rawValue)
}
}
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
fun resolvedVersionName(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
@@ -176,7 +144,7 @@ class ConnectionManager(
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}

View File

@@ -0,0 +1,430 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val DEFAULT_CONTACTS_LIMIT = 25
internal data class ContactRecord(
val identifier: String,
val displayName: String,
val givenName: String,
val familyName: String,
val organizationName: String,
val phoneNumbers: List<String>,
val emails: List<String>,
)
internal data class ContactsSearchRequest(
val query: String?,
val limit: Int,
)
internal data class ContactsAddRequest(
val givenName: String?,
val familyName: String?,
val organizationName: String?,
val displayName: String?,
val phoneNumbers: List<String>,
val emails: List<String>,
)
internal interface ContactsDataSource {
fun hasReadPermission(context: Context): Boolean
fun hasWritePermission(context: Context): Boolean
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
fun add(context: Context, request: ContactsAddRequest): ContactRecord
}
private object SystemContactsDataSource : ContactsDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
val resolver = context.contentResolver
val projection =
arrayOf(
ContactsContract.Contacts._ID,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
)
val selection: String?
val selectionArgs: Array<String>?
if (request.query.isNullOrBlank()) {
selection = null
selectionArgs = null
} else {
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
selectionArgs = arrayOf("%${request.query}%")
}
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
resolver.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val out = mutableListOf<ContactRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val contactId = cursor.getLong(idIndex)
val displayName = cursor.getString(displayNameIndex).orEmpty()
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
}
return out
}
}
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
val resolver = context.contentResolver
val operations = ArrayList<ContentProviderOperation>()
operations +=
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
.withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, request.familyName)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, request.displayName)
.build()
}
if (!request.organizationName.isNullOrEmpty()) {
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
.build()
}
request.phoneNumbers.forEach { number ->
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
.build()
}
request.emails.forEach { email ->
operations +=
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
.withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_HOME)
.build()
}
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
val rawContactUri = results.firstOrNull()?.uri
?: throw IllegalStateException("contact insert failed")
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
return loadContactRecord(
resolver = resolver,
contactId = contactId,
fallbackDisplayName = request.displayName.orEmpty(),
)
}
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
resolver.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
"${ContactsContract.RawContacts._ID}=?",
arrayOf(rawContactId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
return cursor.getLong(index)
}
}
private fun loadContactRecord(
resolver: ContentResolver,
contactId: Long,
fallbackDisplayName: String,
): ContactRecord {
val nameRow = loadNameRow(resolver, contactId)
val organization = loadOrganization(resolver, contactId)
val phones = loadPhones(resolver, contactId)
val emails = loadEmails(resolver, contactId)
val displayName =
when {
!nameRow.displayName.isNullOrEmpty() -> nameRow.displayName
!fallbackDisplayName.isNullOrEmpty() -> fallbackDisplayName
else -> listOfNotNull(nameRow.givenName, nameRow.familyName).joinToString(" ").trim()
}.ifEmpty { "(unnamed)" }
return ContactRecord(
identifier = contactId.toString(),
displayName = displayName,
givenName = nameRow.givenName.orEmpty(),
familyName = nameRow.familyName.orEmpty(),
organizationName = organization.orEmpty(),
phoneNumbers = phones,
emails = emails,
)
}
private data class NameRow(
val givenName: String?,
val familyName: String?,
val displayName: String?,
)
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
val projection =
arrayOf(
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
)
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(
contactId.toString(),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) {
return NameRow(givenName = null, familyName = null, displayName = null)
}
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
return NameRow(givenName = given, familyName = family, displayName = display)
}
}
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getString(0)?.trim()?.ifEmpty { null }
}
}
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
contactId = contactId,
)
}
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
contactId = contactId,
)
}
private fun queryContactValues(
resolver: ContentResolver,
contentUri: android.net.Uri,
valueColumn: String,
contactIdColumn: String,
contactId: Long,
): List<String> {
val projection = arrayOf(valueColumn)
resolver.query(
contentUri,
projection,
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
}
return out.toList()
}
}
}
class ContactsHandler private constructor(
private val appContext: Context,
private val dataSource: ContactsDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CONTACTS_PERMISSION_REQUIRED",
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
)
}
val request =
parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val contacts = dataSource.search(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"contacts",
buildJsonArray {
contacts.forEach { add(contactJson(it)) }
},
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CONTACTS_UNAVAILABLE",
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contacts query failed"}",
)
}
}
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CONTACTS_PERMISSION_REQUIRED",
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
)
}
val request =
parseAddRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val hasName =
!(request.givenName.isNullOrEmpty() && request.familyName.isNullOrEmpty() && request.displayName.isNullOrEmpty())
val hasOrg = !request.organizationName.isNullOrEmpty()
val hasDetails = request.phoneNumbers.isNotEmpty() || request.emails.isNotEmpty()
if (!hasName && !hasOrg && !hasDetails) {
return GatewaySession.InvokeResult.error(
code = "CONTACTS_INVALID",
message = "CONTACTS_INVALID: include a name, organization, phone, or email",
)
}
return try {
val contact = dataSource.add(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put("contact", contactJson(contact))
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CONTACTS_UNAVAILABLE",
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contact add failed"}",
)
}
}
private fun parseSearchRequest(paramsJson: String?): ContactsSearchRequest? {
if (paramsJson.isNullOrBlank()) {
return ContactsSearchRequest(query = null, limit = DEFAULT_CONTACTS_LIMIT)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
return ContactsSearchRequest(query = query, limit = limit)
}
private fun parseAddRequest(paramsJson: String?): ContactsAddRequest? {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
} catch (_: Throwable) {
null
} ?: return null
return ContactsAddRequest(
givenName = (params["givenName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
familyName = (params["familyName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
)
}
private fun stringArray(array: JsonArray?): List<String> {
if (array == null) return emptyList()
return array.mapNotNull { element ->
(element as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
}
}
private fun contactJson(contact: ContactRecord): JsonObject {
return buildJsonObject {
put("identifier", JsonPrimitive(contact.identifier))
put("displayName", JsonPrimitive(contact.displayName))
put("givenName", JsonPrimitive(contact.givenName))
put("familyName", JsonPrimitive(contact.familyName))
put("organizationName", JsonPrimitive(contact.organizationName))
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: ContactsDataSource,
): ContactsHandler = ContactsHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -62,7 +62,8 @@ class DebugHandler(
results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
}
return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""")
val diagnostics = results.joinToString("\n")
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
} catch (e: Throwable) {
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
}

View File

@@ -0,0 +1,413 @@
package ai.openclaw.android.node
import android.Manifest
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class DeviceHandler(
private val appContext: Context,
) {
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
val levelFraction: Double?,
val temperatureC: Double?,
)
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(statusPayloadJson())
}
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(infoPayloadJson())
}
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
}
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(healthPayloadJson())
}
private fun statusPayloadJson(): String {
val battery = readBatterySnapshot()
val powerManager = appContext.getSystemService(PowerManager::class.java)
val storage = StatFs(Environment.getDataDirectory().absolutePath)
val totalBytes = storage.totalBytes
val freeBytes = storage.availableBytes
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivity?.activeNetwork
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
return buildJsonObject {
put(
"battery",
buildJsonObject {
battery.levelFraction?.let { put("level", JsonPrimitive(it)) }
put("state", JsonPrimitive(mapBatteryState(battery.status)))
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
},
)
put(
"thermal",
buildJsonObject {
put("state", JsonPrimitive(mapThermalState(powerManager)))
},
)
put(
"storage",
buildJsonObject {
put("totalBytes", JsonPrimitive(totalBytes))
put("freeBytes", JsonPrimitive(freeBytes))
put("usedBytes", JsonPrimitive(usedBytes))
},
)
put(
"network",
buildJsonObject {
put("status", JsonPrimitive(mapNetworkStatus(caps)))
put(
"isExpensive",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false,
),
)
put(
"isConstrained",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false,
),
)
put("interfaces", networkInterfacesJson(caps))
},
)
put("uptimeSeconds", JsonPrimitive(uptimeSeconds))
}.toString()
}
private fun infoPayloadJson(): String {
val model = Build.MODEL?.trim().orEmpty()
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
val locale = Locale.getDefault().toLanguageTag().trim()
val appVersion = BuildConfig.VERSION_NAME.trim()
val appBuild = BuildConfig.VERSION_CODE.toString()
return buildJsonObject {
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
put("systemName", JsonPrimitive("Android"))
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" }))
put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() }))
}.toString()
}
private fun permissionsPayloadJson(): String {
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
}
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
val notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
true
}
return buildJsonObject {
put(
"permissions",
buildJsonObject {
put(
"camera",
permissionStateJson(
granted = hasPermission(Manifest.permission.CAMERA),
promptableWhenDenied = true,
),
)
put(
"microphone",
permissionStateJson(
granted = hasPermission(Manifest.permission.RECORD_AUDIO),
promptableWhenDenied = true,
),
)
put(
"location",
permissionStateJson(
granted =
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION),
promptableWhenDenied = true,
),
)
put(
"backgroundLocation",
permissionStateJson(
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
promptableWhenDenied = true,
),
)
put(
"sms",
permissionStateJson(
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = canSendSms,
),
)
put(
"notificationListener",
permissionStateJson(
granted = notificationAccess,
promptableWhenDenied = true,
),
)
put(
"notifications",
permissionStateJson(
granted = notificationsGranted,
promptableWhenDenied = true,
),
)
put(
"photos",
permissionStateJson(
granted = photosGranted,
promptableWhenDenied = true,
),
)
put(
"contacts",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CONTACTS),
promptableWhenDenied = true,
),
)
put(
"calendar",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALENDAR),
promptableWhenDenied = true,
),
)
put(
"motion",
permissionStateJson(
granted = motionGranted,
promptableWhenDenied = true,
),
)
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
put(
"screenCapture",
permissionStateJson(
granted = false,
promptableWhenDenied = true,
),
)
},
)
}.toString()
}
private fun healthPayloadJson(): String {
val battery = readBatterySnapshot()
val batteryManager = appContext.getSystemService(BatteryManager::class.java)
val currentNowUa = batteryManager?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
val currentNowMa =
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
null
} else {
currentNowUa.toDouble() / 1_000.0
}
val powerManager = appContext.getSystemService(PowerManager::class.java)
val activityManager = appContext.getSystemService(ActivityManager::class.java)
val memoryInfo = ActivityManager.MemoryInfo()
activityManager?.getMemoryInfo(memoryInfo)
val totalRamBytes = memoryInfo.totalMem.coerceAtLeast(0L)
val availableRamBytes = memoryInfo.availMem.coerceAtLeast(0L)
val usedRamBytes = (totalRamBytes - availableRamBytes).coerceAtLeast(0L)
val lowMemory = memoryInfo.lowMemory
val memoryPressure = mapMemoryPressure(totalRamBytes, availableRamBytes, lowMemory)
return buildJsonObject {
put(
"memory",
buildJsonObject {
put("pressure", JsonPrimitive(memoryPressure))
put("totalRamBytes", JsonPrimitive(totalRamBytes))
put("availableRamBytes", JsonPrimitive(availableRamBytes))
put("usedRamBytes", JsonPrimitive(usedRamBytes))
put("thresholdBytes", JsonPrimitive(memoryInfo.threshold.coerceAtLeast(0L)))
put("lowMemory", JsonPrimitive(lowMemory))
},
)
put(
"battery",
buildJsonObject {
put("state", JsonPrimitive(mapBatteryState(battery.status)))
put("chargingType", JsonPrimitive(mapChargingType(battery.plugged)))
battery.temperatureC?.let { put("temperatureC", JsonPrimitive(it)) }
currentNowMa?.let { put("currentMa", JsonPrimitive(it)) }
},
)
put(
"power",
buildJsonObject {
put("dozeModeEnabled", JsonPrimitive(powerManager?.isDeviceIdleMode == true))
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
},
)
put(
"system",
buildJsonObject {
Build.VERSION.SECURITY_PATCH
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { put("securityPatchLevel", JsonPrimitive(it)) }
},
)
}.toString()
}
private fun readBatterySnapshot(): BatterySnapshot {
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status =
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
?: BatteryManager.BATTERY_STATUS_UNKNOWN
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
val temperatureC =
intent
?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
?.takeIf { it != Int.MIN_VALUE }
?.toDouble()
?.div(10.0)
return BatterySnapshot(
status = status,
plugged = plugged,
levelFraction = batteryLevelFraction(intent),
temperatureC = temperatureC,
)
}
private fun batteryLevelFraction(intent: Intent?): Double? {
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
if (rawLevel < 0 || rawScale <= 0) return null
return rawLevel.toDouble() / rawScale.toDouble()
}
private fun mapBatteryState(status: Int): String {
return when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
else -> "unknown"
}
}
private fun mapChargingType(plugged: Int): String {
return when (plugged) {
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
else -> "none"
}
}
private fun mapThermalState(powerManager: PowerManager?): String {
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
return when (thermal) {
PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal"
PowerManager.THERMAL_STATUS_MODERATE -> "fair"
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
else -> "nominal"
}
}
private fun mapNetworkStatus(caps: NetworkCapabilities?): String {
if (caps == null) return "unsatisfied"
return when {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
else -> "unsatisfied"
}
}
private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
buildJsonObject {
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
}
private fun hasPermission(permission: String): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
)
}
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
if (lowMemory) return "critical"
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
return when {
freeRatio <= 0.05 -> "critical"
freeRatio <= 0.15 -> "high"
freeRatio <= 0.30 -> "moderate"
else -> "normal"
}
}
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
buildJsonArray {
if (caps == null) return@buildJsonArray
var hasKnownTransport = false
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
hasKnownTransport = true
add(JsonPrimitive("wifi"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
hasKnownTransport = true
add(JsonPrimitive("cellular"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
hasKnownTransport = true
add(JsonPrimitive("wired"))
}
if (!hasKnownTransport) add(JsonPrimitive("other"))
}
}

View File

@@ -0,0 +1,377 @@
package ai.openclaw.android.node
import android.app.Notification
import android.app.NotificationManager
import android.app.RemoteInput
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
internal fun sanitizeNotificationText(value: CharSequence?): String? {
val normalized = value?.toString()?.trim().orEmpty()
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
}
data class DeviceNotificationEntry(
val key: String,
val packageName: String,
val title: String?,
val text: String?,
val subText: String?,
val category: String?,
val channelId: String?,
val postTimeMs: Long,
val isOngoing: Boolean,
val isClearable: Boolean,
)
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
return buildJsonObject {
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(packageName))
put("postTimeMs", JsonPrimitive(postTimeMs))
put("isOngoing", JsonPrimitive(isOngoing))
put("isClearable", JsonPrimitive(isClearable))
title?.let { put("title", JsonPrimitive(it)) }
text?.let { put("text", JsonPrimitive(it)) }
subText?.let { put("subText", JsonPrimitive(it)) }
category?.let { put("category", JsonPrimitive(it)) }
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
}
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
val notifications: List<DeviceNotificationEntry>,
)
enum class NotificationActionKind {
Open,
Dismiss,
Reply,
}
data class NotificationActionRequest(
val key: String,
val kind: NotificationActionKind,
val replyText: String? = null,
)
data class NotificationActionResult(
val ok: Boolean,
val code: String? = null,
val message: String? = null,
)
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
return kind == NotificationActionKind.Dismiss
}
private object DeviceNotificationStore {
private val lock = Any()
private var connected = false
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
fun replace(entries: List<DeviceNotificationEntry>) {
synchronized(lock) {
byKey.clear()
for (entry in entries) {
byKey[entry.key] = entry
}
}
}
fun upsert(entry: DeviceNotificationEntry) {
synchronized(lock) {
byKey[entry.key] = entry
}
}
fun remove(key: String) {
synchronized(lock) {
byKey.remove(key)
}
}
fun setConnected(value: Boolean) {
synchronized(lock) {
connected = value
if (!value) {
byKey.clear()
}
}
}
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
val (isConnected, entries) =
synchronized(lock) {
connected to byKey.values.sortedByDescending { it.postTimeMs }
}
return DeviceNotificationSnapshot(
enabled = enabled,
connected = isConnected,
notifications = entries,
)
}
}
class DeviceNotificationListenerService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
activeService = this
DeviceNotificationStore.setConnected(true)
refreshActiveNotifications()
}
override fun onListenerDisconnected() {
if (activeService === this) {
activeService = null
}
DeviceNotificationStore.setConnected(false)
super.onListenerDisconnected()
}
override fun onDestroy() {
if (activeService === this) {
activeService = null
}
super.onDestroy()
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
val entry = sbn?.toEntry() ?: return
DeviceNotificationStore.upsert(entry)
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(),
)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
val removed = sbn ?: return
val key = removed.key.trim()
if (key.isEmpty()) {
return
}
DeviceNotificationStore.remove(key)
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(),
)
}
private fun refreshActiveNotifications() {
val entries =
runCatching {
activeNotifications
?.mapNotNull { it.toEntry() }
?: emptyList()
}.getOrElse { emptyList() }
DeviceNotificationStore.replace(entries)
}
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
val extras = notification.extras
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
val body =
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
return DeviceNotificationEntry(
key = keyValue,
packageName = packageName,
title = title,
text = body,
subText = subText,
category = notification.category?.trim()?.ifEmpty { null },
channelId = notification.channelId?.trim()?.ifEmpty { null },
postTimeMs = postTime,
isOngoing = isOngoing,
isClearable = isClearable,
)
}
companion object {
@Volatile private var activeService: DeviceNotificationListenerService? = null
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
private fun serviceComponent(context: Context): ComponentName {
return ComponentName(context, DeviceNotificationListenerService::class.java)
}
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
nodeEventSink = sink
}
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
return DeviceNotificationStore.snapshot(enabled = enabled)
}
fun requestServiceRebind(context: Context) {
runCatching {
NotificationListenerService.requestRebind(serviceComponent(context))
}
}
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
if (!isAccessEnabled(context)) {
return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_DISABLED",
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
)
}
val service = activeService
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_UNAVAILABLE",
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
)
return service.executeActionInternal(request)
}
private fun emitNotificationsChanged(payloadJson: String) {
runCatching {
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
}
}
}
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
val sbn =
activeNotifications
?.firstOrNull { it.key == request.key }
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATION_NOT_FOUND",
message = "NOTIFICATION_NOT_FOUND: notification key not found",
)
if (actionRequiresClearableNotification(request.kind) && !sbn.isClearable) {
return NotificationActionResult(
ok = false,
code = "NOTIFICATION_NOT_CLEARABLE",
message = "NOTIFICATION_NOT_CLEARABLE: notification is ongoing or protected",
)
}
return when (request.kind) {
NotificationActionKind.Open -> {
val pendingIntent = sbn.notification.contentIntent
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no open action",
)
runCatching {
pendingIntent.send()
}.fold(
onSuccess = { NotificationActionResult(ok = true) },
onFailure = { err ->
NotificationActionResult(
ok = false,
code = "ACTION_FAILED",
message = "ACTION_FAILED: ${err.message ?: "open failed"}",
)
},
)
}
NotificationActionKind.Dismiss -> {
runCatching {
cancelNotification(sbn.key)
DeviceNotificationStore.remove(sbn.key)
}.fold(
onSuccess = { NotificationActionResult(ok = true) },
onFailure = { err ->
NotificationActionResult(
ok = false,
code = "ACTION_FAILED",
message = "ACTION_FAILED: ${err.message ?: "dismiss failed"}",
)
},
)
}
NotificationActionKind.Reply -> {
val replyText = request.replyText?.trim().orEmpty()
if (replyText.isEmpty()) {
return NotificationActionResult(
ok = false,
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: replyText required for reply action",
)
}
val action =
sbn.notification.actions
?.firstOrNull { candidate ->
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
}
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no reply action",
)
val remoteInputs = action.remoteInputs ?: emptyArray()
val fillInIntent = Intent()
val replyBundle = android.os.Bundle()
for (remoteInput in remoteInputs) {
replyBundle.putCharSequence(remoteInput.resultKey, replyText)
}
RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, replyBundle)
runCatching {
action.actionIntent.send(this, 0, fillInIntent)
}.fold(
onSuccess = { NotificationActionResult(ok = true) },
onFailure = { err ->
NotificationActionResult(
ok = false,
code = "ACTION_FAILED",
message = "ACTION_FAILED: ${err.message ?: "reply failed"}",
)
},
)
}
}
}
}

View File

@@ -0,0 +1,242 @@
package ai.openclaw.android.node
import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawCapability
import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
val debugBuild: Boolean,
)
enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
}
enum class NodeCapabilityAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
VoiceWakeEnabled,
MotionAvailable,
}
data class NodeCapabilitySpec(
val name: String,
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
)
data class InvokeCommandSpec(
val name: String,
val requiresForeground: Boolean = false,
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
)
object InvokeCommandRegistry {
val capabilityManifest: List<NodeCapabilitySpec> =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.Camera.rawValue,
availability = NodeCapabilityAvailability.CameraEnabled,
),
NodeCapabilitySpec(
name = OpenClawCapability.Sms.rawValue,
availability = NodeCapabilityAvailability.SmsAvailable,
),
NodeCapabilitySpec(
name = OpenClawCapability.VoiceWake.rawValue,
availability = NodeCapabilityAvailability.VoiceWakeEnabled,
),
NodeCapabilitySpec(
name = OpenClawCapability.Location.rawValue,
availability = NodeCapabilityAvailability.LocationEnabled,
),
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
)
val all: List<InvokeCommandSpec> =
listOf(
InvokeCommandSpec(
name = OpenClawCanvasCommand.Present.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Hide.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Navigate.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Eval.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasCommand.Snapshot.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.Push.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCanvasA2UICommand.Reset.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawScreenCommand.Record.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawSystemCommand.Notify.rawValue,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.List.rawValue,
requiresForeground = true,
availability = InvokeCommandAvailability.CameraEnabled,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.Snap.rawValue,
requiresForeground = true,
availability = InvokeCommandAvailability.CameraEnabled,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.Clip.rawValue,
requiresForeground = true,
availability = InvokeCommandAvailability.CameraEnabled,
),
InvokeCommandSpec(
name = OpenClawLocationCommand.Get.rawValue,
availability = InvokeCommandAvailability.LocationEnabled,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Status.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Info.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Permissions.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Health.rawValue,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.List.rawValue,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.Actions.rawValue,
),
InvokeCommandSpec(
name = OpenClawPhotosCommand.Latest.rawValue,
),
InvokeCommandSpec(
name = OpenClawContactsCommand.Search.rawValue,
),
InvokeCommandSpec(
name = OpenClawContactsCommand.Add.rawValue,
),
InvokeCommandSpec(
name = OpenClawCalendarCommand.Events.rawValue,
),
InvokeCommandSpec(
name = OpenClawCalendarCommand.Add.rawValue,
),
InvokeCommandSpec(
name = OpenClawMotionCommand.Activity.rawValue,
availability = InvokeCommandAvailability.MotionActivityAvailable,
),
InvokeCommandSpec(
name = OpenClawMotionCommand.Pedometer.rawValue,
availability = InvokeCommandAvailability.MotionPedometerAvailable,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
),
InvokeCommandSpec(
name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(
name = "debug.ed25519",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(name = "app.update"),
)
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> {
return capabilityManifest
.filter { spec ->
when (spec.availability) {
NodeCapabilityAvailability.Always -> true
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
}
.map { it.name }
}
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> {
return all
.filter { spec ->
when (spec.availability) {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}
.map { it.name }
}
}

View File

@@ -1,17 +1,30 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawSystemCommand
class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
private val locationHandler: LocationHandler,
private val deviceHandler: DeviceHandler,
private val notificationsHandler: NotificationsHandler,
private val systemHandler: SystemHandler,
private val photosHandler: PhotosHandler,
private val contactsHandler: ContactsHandler,
private val calendarHandler: CalendarHandler,
private val motionHandler: MotionHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
@@ -20,38 +33,28 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) {
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
// Check foreground requirement for canvas/camera/screen commands
if (
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
) {
if (!isForeground()) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
val spec =
InvokeCommandRegistry.find(command)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
// Check camera enabled
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
if (spec.requiresForeground && !isForeground()) {
return GatewaySession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
// Check location enabled
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
)
}
availabilityError(spec.availability)?.let { return it }
return when (command) {
// Canvas commands
@@ -73,52 +76,33 @@ class InvokeDispatcher(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
val result =
try {
canvas.eval(js)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
withCanvasAvailable {
val result = canvas.eval(js)
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
}
OpenClawCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
val base64 =
try {
withCanvasAvailable {
val base64 =
canvas.snapshotBase64(
format = snapshotParams.format,
quality = snapshotParams.quality,
maxWidth = snapshotParams.maxWidth,
)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
}
// A2UI commands
OpenClawCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
OpenClawCanvasA2UICommand.Reset.rawValue ->
withReadyA2ui {
withCanvasAvailable {
val res = canvas.eval(A2UIHandler.a2uiResetJS)
onCanvasA2uiReset()
GatewaySession.InvokeResult.ok(res)
}
}
val res = canvas.eval(A2UIHandler.a2uiResetJS)
GatewaySession.InvokeResult.ok(res)
}
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
val messages =
try {
@@ -129,30 +113,54 @@ class InvokeDispatcher(
message = err.message ?: "invalid A2UI payload"
)
}
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
withReadyA2ui {
withCanvasAvailable {
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
onCanvasA2uiPush()
GatewaySession.InvokeResult.ok(res)
}
}
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
GatewaySession.InvokeResult.ok(res)
}
// Camera commands
OpenClawCameraCommand.List.rawValue -> cameraHandler.handleList(paramsJson)
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson)
// Location command
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
// Device commands
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
// Notifications command
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleNotificationsActions(paramsJson)
// System command
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
// Photos command
ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
paramsJson,
)
// Contacts command
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
OpenClawContactsCommand.Add.rawValue -> contactsHandler.handleContactsAdd(paramsJson)
// Calendar command
OpenClawCalendarCommand.Events.rawValue -> calendarHandler.handleCalendarEvents(paramsJson)
OpenClawCalendarCommand.Add.rawValue -> calendarHandler.handleCalendarAdd(paramsJson)
// Motion command
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
// Screen command
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
@@ -166,11 +174,111 @@ class InvokeDispatcher(
// App update
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
else ->
GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}
}
private suspend fun withReadyA2ui(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!readyOnFirstCheck) {
if (!refreshNodeCanvasCapability()) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
)
}
a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
)
}
}
return block()
}
private suspend fun withCanvasAvailable(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
return try {
block()
} catch (_: Throwable) {
GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
}
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
return when (availability) {
InvokeCommandAvailability.Always -> null
InvokeCommandAvailability.CameraEnabled ->
if (cameraEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
InvokeCommandAvailability.LocationEnabled ->
if (locationEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
InvokeCommandAvailability.MotionActivityAvailable ->
if (motionActivityAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE",
message = "MOTION_UNAVAILABLE: accelerometer not available",
)
}
InvokeCommandAvailability.MotionPedometerAvailable ->
if (motionPedometerAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "PEDOMETER_UNAVAILABLE",
message = "PEDOMETER_UNAVAILABLE: step counter not available",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
}
}

View File

@@ -0,0 +1,375 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import java.time.Instant
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
private const val ACCELEROMETER_SAMPLE_TARGET = 20
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
internal data class MotionActivityRequest(
val startISO: String?,
val endISO: String?,
val limit: Int,
)
internal data class MotionPedometerRequest(
val startISO: String?,
val endISO: String?,
)
internal data class MotionActivityRecord(
val startISO: String,
val endISO: String,
val confidence: String,
val isWalking: Boolean,
val isRunning: Boolean,
val isCycling: Boolean,
val isAutomotive: Boolean,
val isStationary: Boolean,
val isUnknown: Boolean,
)
internal data class PedometerRecord(
val startISO: String,
val endISO: String,
val steps: Int?,
val distanceMeters: Double?,
val floorsAscended: Int?,
val floorsDescended: Int?,
)
internal interface MotionDataSource {
fun isActivityAvailable(context: Context): Boolean
fun isPedometerAvailable(context: Context): Boolean
fun isAvailable(context: Context): Boolean = isActivityAvailable(context) || isPedometerAvailable(context)
fun hasPermission(context: Context): Boolean
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
}
private object SystemMotionDataSource : MotionDataSource {
override fun isActivityAvailable(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java)
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}
override fun isPedometerAvailable(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java)
return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
override fun hasPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
}
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
val sample = readAccelerometerSample(sensorManager, accelerometer)
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
val end = Instant.now()
val start = end.minusSeconds(2)
val classification = classifyActivity(sample.averageDelta)
return MotionActivityRecord(
startISO = start.toString(),
endISO = end.toString(),
confidence = classifyConfidence(sample.samples, sample.averageDelta),
isWalking = classification == "walking",
isRunning = classification == "running",
isCycling = false,
isAutomotive = false,
isStationary = classification == "stationary",
isUnknown = classification == "unknown",
)
}
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
}
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
val steps = readStepCounter(sensorManager, stepCounter)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
return PedometerRecord(
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
endISO = Instant.now().toString(),
steps = steps,
distanceMeters = null,
floorsAscended = null,
floorsDescended = null,
)
}
private data class AccelerometerSample(
val samples: Int,
val averageDelta: Double,
)
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
val sample =
withTimeoutOrNull(1200L) {
suspendCancellableCoroutine<Float?> { cont ->
var resumed = false
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (resumed) return
val value = event?.values?.firstOrNull()
resumed = true
sensorManager.unregisterListener(this)
cont.resume(value)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
sensorManager.unregisterListener(listener)
resumed = true
cont.resume(null)
return@suspendCancellableCoroutine
}
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
}
}
return sample?.toInt()?.takeIf { it >= 0 }
}
private suspend fun readAccelerometerSample(
sensorManager: SensorManager,
sensor: Sensor,
): AccelerometerSample? {
val sample =
withTimeoutOrNull(ACCELEROMETER_SAMPLE_TIMEOUT_MS) {
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
var count = 0
var sumDelta = 0.0
var resumed = false
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
val values = event?.values ?: return
if (values.size < 3) return
val magnitude =
sqrt(
values[0] * values[0] +
values[1] * values[1] +
values[2] * values[2],
).toDouble()
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
resumed = true
sensorManager.unregisterListener(this)
cont.resume(
AccelerometerSample(
samples = count,
averageDelta = if (count == 0) 0.0 else sumDelta / count,
),
)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
resumed = true
cont.resume(null)
return@suspendCancellableCoroutine
}
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
}
}
return sample
}
private fun classifyActivity(averageDelta: Double): String {
return when {
averageDelta <= 0.55 -> "stationary"
averageDelta <= 1.80 -> "walking"
else -> "running"
}
}
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
if (samples < 6) return "low"
if (samples >= 14 && averageDelta > 0.4) return "high"
return "medium"
}
}
class MotionHandler private constructor(
private val appContext: Context,
private val dataSource: MotionDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "MOTION_PERMISSION_REQUIRED",
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
)
}
val request =
parseActivityRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val activity = dataSource.activity(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"activities",
buildJsonArray {
add(
buildJsonObject {
put("startISO", JsonPrimitive(activity.startISO))
put("endISO", JsonPrimitive(activity.endISO))
put("confidence", JsonPrimitive(activity.confidence))
put("isWalking", JsonPrimitive(activity.isWalking))
put("isRunning", JsonPrimitive(activity.isRunning))
put("isCycling", JsonPrimitive(activity.isCycling))
put("isAutomotive", JsonPrimitive(activity.isAutomotive))
put("isStationary", JsonPrimitive(activity.isStationary))
put("isUnknown", JsonPrimitive(activity.isUnknown))
},
)
},
)
}.toString(),
)
} catch (err: IllegalArgumentException) {
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE",
message = "MOTION_UNAVAILABLE: ${err.message ?: "motion activity failed"}",
)
}
}
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "MOTION_PERMISSION_REQUIRED",
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
)
}
val request =
parsePedometerRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val payload = dataSource.pedometer(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put("startISO", JsonPrimitive(payload.startISO))
put("endISO", JsonPrimitive(payload.endISO))
payload.steps?.let { put("steps", JsonPrimitive(it)) }
payload.distanceMeters?.let { put("distanceMeters", JsonPrimitive(it)) }
payload.floorsAscended?.let { put("floorsAscended", JsonPrimitive(it)) }
payload.floorsDescended?.let { put("floorsDescended", JsonPrimitive(it)) }
}.toString(),
)
} catch (err: IllegalArgumentException) {
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE",
message = "MOTION_UNAVAILABLE: ${err.message ?: "pedometer query failed"}",
)
}
}
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
if (paramsJson.isNullOrBlank()) {
return MotionActivityRequest(startISO = null, endISO = null, limit = 200)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
return MotionActivityRequest(
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
limit = limit,
)
}
private fun parsePedometerRequest(paramsJson: String?): MotionPedometerRequest? {
if (paramsJson.isNullOrBlank()) {
return MotionPedometerRequest(startISO = null, endISO = null)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
return MotionPedometerRequest(
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
)
}
companion object {
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
internal fun forTesting(
appContext: Context,
dataSource: MotionDataSource,
): MotionHandler = MotionHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -1,9 +1,12 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
@@ -20,6 +23,35 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun parseJsonInt(params: JsonObject?, key: String): Int? =
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonString(params: JsonObject?, key: String): String? =
readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
@@ -37,14 +69,9 @@ fun parseHexColorArgb(raw: String?): Long? {
}
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
val raw = (err.message ?: "").trim()
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
val idx = raw.indexOf(':')
if (idx <= 0) return "UNAVAILABLE" to raw
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
return code to "$code: $message"
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
return parsed.code to message
}
fun normalizeMainKey(raw: String?): String? {

View File

@@ -0,0 +1,161 @@
package ai.openclaw.android.node
import android.content.Context
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
internal interface NotificationsStateProvider {
fun readSnapshot(context: Context): DeviceNotificationSnapshot
fun requestServiceRebind(context: Context)
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
if (!enabled) {
return DeviceNotificationSnapshot(
enabled = false,
connected = false,
notifications = emptyList(),
)
}
return DeviceNotificationListenerService.snapshot(context, enabled = true)
}
override fun requestServiceRebind(context: Context) {
DeviceNotificationListenerService.requestServiceRebind(context)
}
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
return DeviceNotificationListenerService.executeAction(context, request)
}
}
class NotificationsHandler private constructor(
private val appContext: Context,
private val stateProvider: NotificationsStateProvider,
) {
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
val snapshot = readSnapshotWithRebind()
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
}
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
readSnapshotWithRebind()
val params = parseParamsObject(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val key =
readString(params, "key")
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: key required",
)
val actionRaw =
readString(params, "action")?.lowercase()
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
)
val action =
when (actionRaw) {
"open" -> NotificationActionKind.Open
"dismiss" -> NotificationActionKind.Dismiss
"reply" -> NotificationActionKind.Reply
else ->
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: action must be open|dismiss|reply",
)
}
val replyText = readString(params, "replyText")
if (action == NotificationActionKind.Reply && replyText.isNullOrBlank()) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: replyText required for reply action",
)
}
val result =
stateProvider.executeAction(
appContext,
NotificationActionRequest(
key = key,
kind = action,
replyText = replyText,
),
)
if (!result.ok) {
return GatewaySession.InvokeResult.error(
code = result.code ?: "UNAVAILABLE",
message = result.message ?: "notification action failed",
)
}
val payload =
buildJsonObject {
put("ok", JsonPrimitive(true))
put("key", JsonPrimitive(key))
put("action", JsonPrimitive(actionRaw))
}.toString()
return GatewaySession.InvokeResult.ok(payload)
}
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
val snapshot = stateProvider.readSnapshot(appContext)
if (snapshot.enabled && !snapshot.connected) {
stateProvider.requestServiceRebind(appContext)
}
return snapshot
}
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
return buildJsonObject {
put("enabled", JsonPrimitive(snapshot.enabled))
put("connected", JsonPrimitive(snapshot.connected))
put("count", JsonPrimitive(snapshot.notifications.size))
put(
"notifications",
JsonArray(
snapshot.notifications.map { entry -> entry.toJsonObject() },
),
)
}.toString()
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
private fun readString(params: JsonObject, key: String): String? =
(params[key] as? JsonPrimitive)
?.contentOrNull
?.trim()
?.takeIf { it.isNotEmpty() }
companion object {
internal fun forTesting(
appContext: Context,
stateProvider: NotificationsStateProvider,
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
}
}

View File

@@ -0,0 +1,288 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import ai.openclaw.android.gateway.GatewaySession
import java.io.ByteArrayOutputStream
import java.time.Instant
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val DEFAULT_PHOTOS_LIMIT = 1
private const val DEFAULT_PHOTOS_MAX_WIDTH = 1600
private const val DEFAULT_PHOTOS_QUALITY = 0.85
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
internal data class PhotosLatestRequest(
val limit: Int,
val maxWidth: Int,
val quality: Double,
)
internal data class EncodedPhotoPayload(
val format: String,
val base64: String,
val width: Int,
val height: Int,
val createdAt: String?,
)
internal interface PhotosDataSource {
fun hasPermission(context: Context): Boolean
fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload>
}
private object SystemPhotosDataSource : PhotosDataSource {
override fun hasPermission(context: Context): Boolean {
val permission =
if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
return ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> {
val resolver = context.contentResolver
val rows = queryLatestRows(resolver, request.limit)
if (rows.isEmpty()) return emptyList()
var remainingBudget = MAX_TOTAL_BASE64_CHARS
val out = mutableListOf<EncodedPhotoPayload>()
for (row in rows) {
if (remainingBudget <= 0) break
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
if (encoded.base64.length > remainingBudget) break
remainingBudget -= encoded.base64.length
out +=
EncodedPhotoPayload(
format = "jpeg",
base64 = encoded.base64,
width = encoded.width,
height = encoded.height,
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
)
}
return out
}
private data class PhotoRow(
val uri: Uri,
val createdAtMs: Long?,
)
private data class EncodedJpeg(
val base64: String,
val width: Int,
val height: Int,
)
private fun queryLatestRows(resolver: ContentResolver, limit: Int): List<PhotoRow> {
val projection =
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.DATE_ADDED,
)
val sortOrder =
"${MediaStore.Images.Media.DATE_TAKEN} DESC, ${MediaStore.Images.Media.DATE_ADDED} DESC"
val args =
Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder)
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
}
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
args,
null,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val rows = mutableListOf<PhotoRow>()
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
rows +=
PhotoRow(
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
createdAtMs = takenMs ?: addedMs,
)
}
return rows
}
}
private fun decodeScaledBitmap(
resolver: ContentResolver,
uri: Uri,
maxWidth: Int,
): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri).use { input ->
if (input == null) return null
BitmapFactory.decodeStream(input, null, bounds)
}
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val inSampleSize = computeInSampleSize(bounds.outWidth, maxWidth)
val decodeOptions = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize }
val decoded =
resolver.openInputStream(uri).use { input ->
if (input == null) return null
BitmapFactory.decodeStream(input, null, decodeOptions)
} ?: return null
if (decoded.width <= maxWidth) return decoded
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
return decoded.scale(maxWidth, targetHeight, true)
}
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
var sample = 1
var candidate = width
while (candidate > maxWidth && sample < 64) {
sample *= 2
candidate = width / sample
}
return sample
}
private fun encodeJpegUnderBudget(
bitmap: Bitmap,
quality: Double,
maxBase64Chars: Int,
): EncodedJpeg? {
var working = bitmap
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
repeat(10) {
val out = ByteArrayOutputStream()
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
if (!ok) return null
val bytes = out.toByteArray()
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
if (base64.length <= maxBase64Chars) {
return EncodedJpeg(
base64 = base64,
width = working.width,
height = working.height,
)
}
if (jpegQuality > 35) {
jpegQuality = max(25, jpegQuality - 15)
return@repeat
}
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
if (nextWidth >= working.width) return null
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
working = working.scale(nextWidth, nextHeight, true)
}
return null
}
}
class PhotosHandler private constructor(
private val appContext: Context,
private val dataSource: PhotosDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "PHOTOS_PERMISSION_REQUIRED",
message = "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
)
}
val request =
parseRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val photos = dataSource.latest(appContext, request)
val payload =
buildJsonObject {
put(
"photos",
buildJsonArray {
photos.forEach { photo ->
add(
buildJsonObject {
put("format", JsonPrimitive(photo.format))
put("base64", JsonPrimitive(photo.base64))
put("width", JsonPrimitive(photo.width))
put("height", JsonPrimitive(photo.height))
photo.createdAt?.let { put("createdAt", JsonPrimitive(it)) }
},
)
}
},
)
}.toString()
GatewaySession.InvokeResult.ok(payload)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "PHOTOS_UNAVAILABLE",
message = "PHOTOS_UNAVAILABLE: ${err.message ?: "photo fetch failed"}",
)
}
}
private fun parseRequest(paramsJson: String?): PhotosLatestRequest? {
if (paramsJson.isNullOrBlank()) {
return PhotosLatestRequest(
limit = DEFAULT_PHOTOS_LIMIT,
maxWidth = DEFAULT_PHOTOS_MAX_WIDTH,
quality = DEFAULT_PHOTOS_QUALITY,
)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limitRaw = (params["limit"] as? JsonPrimitive)?.content?.toIntOrNull()
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
return PhotosLatestRequest(limit = limit, maxWidth = maxWidth, quality = quality)
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: PhotosDataSource,
): PhotosHandler = PhotosHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -10,6 +10,7 @@ import ai.openclaw.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.File
import kotlin.math.roundToInt
@@ -35,12 +36,13 @@ class ScreenRecordManager(private val context: Context) {
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
val params = parseJsonParamsObject(paramsJson)
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
val fpsInt = fps.roundToInt().coerceIn(1, 60)
val screenIndex = parseScreenIndex(paramsJson)
val includeAudio = parseIncludeAudio(paramsJson) ?: true
val format = parseString(paramsJson, key = "format")
val screenIndex = parseScreenIndex(params)
val includeAudio = parseIncludeAudio(params) ?: true
val format = parseString(params, key = "format")
if (format != null && format.lowercase() != "mp4") {
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
}
@@ -141,55 +143,19 @@ class ScreenRecordManager(private val context: Context) {
}
}
private fun parseDurationMs(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseFps(paramsJson: String?): Double? =
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
private fun parseFps(params: JsonObject?): Double? =
parseJsonDouble(params, "fps")
private fun parseScreenIndex(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
private fun parseScreenIndex(params: JsonObject?): Int? =
parseJsonInt(params, "screenIndex")
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
val raw = paramsJson ?: return null
val key = "\"includeAudio\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return when {
tail.startsWith("true") -> true
tail.startsWith("false") -> false
else -> null
}
}
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseNumber(paramsJson: String?, key: String): String? {
val raw = paramsJson ?: return null
val needle = "\"$key\""
val idx = raw.indexOf(needle)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + needle.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
}
private fun parseString(paramsJson: String?, key: String): String? {
val raw = paramsJson ?: return null
val needle = "\"$key\""
val idx = raw.indexOf(needle)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + needle.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
if (!tail.startsWith('\"')) return null
val rest = tail.drop(1)
val end = rest.indexOf('\"')
if (end < 0) return null
return rest.substring(0, end)
}
private fun parseString(params: JsonObject?, key: String): String? =
parseJsonString(params, key)
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()

View File

@@ -0,0 +1,172 @@
package ai.openclaw.android.node
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
internal data class SystemNotifyRequest(
val title: String,
val body: String,
val sound: String?,
val priority: String?,
)
internal interface SystemNotificationPoster {
fun isAuthorized(): Boolean
fun post(request: SystemNotifyRequest)
}
private class AndroidSystemNotificationPoster(
private val appContext: Context,
) : SystemNotificationPoster {
override fun isAuthorized(): Boolean {
if (Build.VERSION.SDK_INT >= 33) {
val granted =
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) return false
}
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
}
override fun post(request: SystemNotifyRequest) {
val channelId = ensureChannel(request.priority)
val silent = isSilentSound(request.sound)
val notification =
NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(request.title)
.setContentText(request.body)
.setPriority(compatPriority(request.priority))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setSilent(silent)
.build()
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
throw SecurityException("notifications permission missing")
}
NotificationManagerCompat.from(appContext).notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), notification)
}
private fun ensureChannel(priority: String?): String {
val normalizedPriority = priority.orEmpty().trim().lowercase()
val (suffix, importance, name) =
when (normalizedPriority) {
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
"timesensitive" -> Triple("timesensitive", NotificationManager.IMPORTANCE_HIGH, "OpenClaw Time Sensitive")
else -> Triple("active", NotificationManager.IMPORTANCE_DEFAULT, "OpenClaw Active")
}
val channelId = "$NOTIFICATION_CHANNEL_BASE_ID.$suffix"
val manager = appContext.getSystemService(NotificationManager::class.java)
val existing = manager.getNotificationChannel(channelId)
if (existing == null) {
manager.createNotificationChannel(NotificationChannel(channelId, name, importance))
}
return channelId
}
private fun compatPriority(priority: String?): Int {
return when (priority.orEmpty().trim().lowercase()) {
"passive" -> NotificationCompat.PRIORITY_LOW
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
}
}
private fun isSilentSound(sound: String?): Boolean {
val normalized = sound?.trim()?.lowercase() ?: return false
return normalized in setOf("none", "silent", "off", "false", "0")
}
}
class SystemHandler private constructor(
private val poster: SystemNotificationPoster,
) {
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
val params =
parseNotifyRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object with title/body",
)
if (params.title.isEmpty() && params.body.isEmpty()) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: empty notification",
)
}
if (!poster.isAuthorized()) {
return GatewaySession.InvokeResult.error(
code = "NOT_AUTHORIZED",
message = "NOT_AUTHORIZED: notifications",
)
}
return try {
poster.post(params)
GatewaySession.InvokeResult.ok(null)
} catch (_: SecurityException) {
GatewaySession.InvokeResult.error(
code = "NOT_AUTHORIZED",
message = "NOT_AUTHORIZED: notifications",
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "UNAVAILABLE",
message = "NOTIFICATION_FAILED: ${err.message ?: "notification post failed"}",
)
}
}
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
val params = parseParamsObject(paramsJson) ?: return null
val rawTitle =
(params["title"] as? JsonPrimitive)
?.contentOrNull
?: return null
val rawBody =
(params["body"] as? JsonPrimitive)
?.contentOrNull
?: return null
val sound = (params["sound"] as? JsonPrimitive)?.contentOrNull
val priority = (params["priority"] as? JsonPrimitive)?.contentOrNull
return SystemNotifyRequest(
title = rawTitle.trim(),
body = rawBody.trim(),
sound = sound?.trim()?.ifEmpty { null },
priority = priority?.trim()?.ifEmpty { null },
)
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
companion object {
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
}
}

View File

@@ -7,6 +7,14 @@ enum class OpenClawCapability(val rawValue: String) {
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
Notifications("notifications"),
System("system"),
AppUpdate("appUpdate"),
Photos("photos"),
Contacts("contacts"),
Calendar("calendar"),
Motion("motion"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -34,6 +42,7 @@ enum class OpenClawCanvasA2UICommand(val rawValue: String) {
}
enum class OpenClawCameraCommand(val rawValue: String) {
List("camera.list"),
Snap("camera.snap"),
Clip("camera.clip"),
;
@@ -69,3 +78,73 @@ enum class OpenClawLocationCommand(val rawValue: String) {
const val NamespacePrefix: String = "location."
}
}
enum class OpenClawDeviceCommand(val rawValue: String) {
Status("device.status"),
Info("device.info"),
Permissions("device.permissions"),
Health("device.health"),
;
companion object {
const val NamespacePrefix: String = "device."
}
}
enum class OpenClawNotificationsCommand(val rawValue: String) {
List("notifications.list"),
Actions("notifications.actions"),
;
companion object {
const val NamespacePrefix: String = "notifications."
}
}
enum class OpenClawSystemCommand(val rawValue: String) {
Notify("system.notify"),
;
companion object {
const val NamespacePrefix: String = "system."
}
}
enum class OpenClawPhotosCommand(val rawValue: String) {
Latest("photos.latest"),
;
companion object {
const val NamespacePrefix: String = "photos."
}
}
enum class OpenClawContactsCommand(val rawValue: String) {
Search("contacts.search"),
Add("contacts.add"),
;
companion object {
const val NamespacePrefix: String = "contacts."
}
}
enum class OpenClawCalendarCommand(val rawValue: String) {
Events("calendar.events"),
Add("calendar.add"),
;
companion object {
const val NamespacePrefix: String = "calendar."
}
}
enum class OpenClawMotionCommand(val rawValue: String) {
Activity("motion.activity"),
Pedometer("motion.pedometer"),
;
companion object {
const val NamespacePrefix: String = "motion."
}
}

View File

@@ -0,0 +1,150 @@
package ai.openclaw.android.ui
import android.annotation.SuppressLint
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import ai.openclaw.android.MainViewModel
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
val webViewRef = remember { mutableStateOf<WebView?>(null) }
DisposableEffect(viewModel) {
onDispose {
val webView = webViewRef.value ?: return@onDispose
viewModel.canvas.detach(webView)
webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName)
webView.stopLoading()
webView.destroy()
webViewRef.value = null
}
}
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.useWideViewPort = false
settings.loadWithOverviewMode = false
settings.builtInZoomControls = false
settings.displayZoomControls = false
settings.setSupportZoom(false)
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) }
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
webViewRef.value = this
}
},
)
}
private fun disableForceDarkIfSupported(settings: WebSettings) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
}
}

View File

@@ -0,0 +1,493 @@
package ai.openclaw.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import ai.openclaw.android.MainViewModel
private enum class ConnectInputMode {
SetupCode,
Manual,
}
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
var inputMode by
remember(manualEnabled, manualHost, gatewayToken) {
mutableStateOf(
if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) {
ConnectInputMode.Manual
} else {
ConnectInputMode.SetupCode
},
)
}
var setupCode by rememberSaveable { mutableStateOf("") }
var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) }
var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) }
var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) }
var passwordInput by rememberSaveable { mutableStateOf("") }
var validationText by rememberSaveable { mutableStateOf<String?>(null) }
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},
)
}
val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } }
val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) {
composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl }
}
val activeEndpoint =
remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) {
when {
isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!!
inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set"
else -> manualResolvedEndpoint ?: "Not set"
}
}
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent)
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
Text(
"One primary action. Open advanced controls only when needed.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(statusText, style = mobileBody, color = mobileText)
}
}
Button(
onClick = {
if (isConnected) {
viewModel.disconnect()
validationText = null
return@Button
}
if (statusText.contains("operator offline", ignoreCase = true)) {
validationText = null
viewModel.refreshGatewayConnection()
return@Button
}
val config =
resolveGatewayConnectConfig(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
manualHost = manualHostInput,
manualPort = manualPortInput,
manualTls = manualTlsInput,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
)
if (config == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
"Paste a valid setup code to connect."
} else {
"Enter a valid manual host and port to connect."
}
return@Button
}
validationText = null
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token)
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (isConnected) mobileDanger else mobileAccent,
contentColor = Color.White,
),
) {
Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
onClick = { advancedOpen = !advancedOpen },
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced controls", style = mobileHeadline, color = mobileText)
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
}
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls",
tint = mobileTextSecondary,
)
}
}
AnimatedVisibility(visible = advancedOpen) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MethodChip(
label = "Setup Code",
active = inputMode == ConnectInputMode.SetupCode,
onClick = { inputMode = ConnectInputMode.SetupCode },
)
MethodChip(
label = "Manual",
active = inputMode == ConnectInputMode.Manual,
onClick = { inputMode = ConnectInputMode.Manual },
)
}
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = {
setupCode = it
validationText = null
},
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
if (!setupResolvedEndpoint.isNullOrBlank()) {
EndpointPreview(endpoint = setupResolvedEndpoint)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
QuickFillChip(
label = "Android Emulator",
onClick = {
manualHostInput = "10.0.2.2"
manualPortInput = "18789"
manualTlsInput = false
validationText = null
},
)
QuickFillChip(
label = "Localhost",
onClick = {
manualHostInput = "127.0.0.1"
manualPortInput = "18789"
manualTlsInput = false
validationText = null
},
)
}
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualHostInput,
onValueChange = {
manualHostInput = it
validationText = null
},
placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualPortInput,
onValueChange = {
manualPortInput = it
validationText = null
},
placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
}
Switch(
checked = manualTlsInput,
onCheckedChange = {
manualTlsInput = it
validationText = null
},
colors =
SwitchDefaults.colors(
checkedTrackColor = mobileAccent,
uncheckedTrackColor = mobileBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
)
}
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = { viewModel.setGatewayToken(it) },
placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = passwordInput,
onValueChange = { passwordInput = it },
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = mobileBody.copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = outlinedColors(),
)
if (!manualResolvedEndpoint.isNullOrBlank()) {
EndpointPreview(endpoint = manualResolvedEndpoint)
}
}
HorizontalDivider(color = mobileBorder)
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
}
}
}
}
if (!validationText.isNullOrBlank()) {
Text(validationText!!, style = mobileCaption1, color = mobileWarning)
}
}
}
@Composable
private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.height(40.dp),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (active) mobileAccent else mobileSurface,
contentColor = if (active) Color.White else mobileText,
),
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
}
}
@Composable
private fun QuickFillChip(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(999.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileAccentSoft,
contentColor = mobileAccent,
),
elevation = null,
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold))
}
}
@Composable
private fun CommandBlock(command: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
Text(
text = command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
color = mobileCodeText,
)
}
}
}
@Composable
private fun EndpointPreview(endpoint: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
HorizontalDivider(color = mobileBorder)
Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText)
HorizontalDivider(color = mobileBorder)
}
}
@Composable
private fun outlinedColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)

View File

@@ -0,0 +1,142 @@
package ai.openclaw.android.ui
import androidx.core.net.toUri
import java.util.Base64
import java.util.Locale
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
internal data class GatewayEndpointConfig(
val host: String,
val port: Int,
val tls: Boolean,
val displayUrl: String,
)
internal data class GatewaySetupCode(
val url: String,
val token: String?,
val password: String?,
)
internal data class GatewayConnectConfig(
val host: String,
val port: Int,
val tls: Boolean,
val token: String,
val password: String,
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
manualHost: String,
manualPort: String,
manualTls: Boolean,
fallbackToken: String,
fallbackPassword: String,
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
token = setup.token ?: fallbackToken.trim(),
password = setup.password ?: fallbackPassword.trim(),
)
}
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)
}
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
val raw = rawInput.trim()
if (raw.isEmpty()) return null
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri = normalized.toUri()
val host = uri.host?.trim().orEmpty()
if (host.isEmpty()) return null
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}
val port = uri.port.takeIf { it in 1..65535 } ?: 18789
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
}
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
val padded =
trimmed
.replace('-', '+')
.replace('_', '/')
.let { normalized ->
val remainder = normalized.length % 4
if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder)
}
return try {
val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8)
val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
val token = jsonField(obj, "token")
val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password)
} catch (_: IllegalArgumentException) {
null
}
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
return setupCode.takeIf { decodeGatewaySetupCode(it) != null }
}
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
val host = hostInput.trim()
val port = portInput.trim().toIntOrNull() ?: return null
if (host.isEmpty() || port !in 1..65535) return null
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
}
private fun parseJsonObject(input: String): JsonObject? {
return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
}
private fun resolveSetupCodeCandidate(rawInput: String): String? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") }
return qrSetupCode ?: trimmed
}
private fun jsonField(obj: JsonObject, key: String): String? {
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}

View File

@@ -0,0 +1,106 @@
package ai.openclaw.android.ui
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ai.openclaw.android.R
internal val mobileBackgroundGradient =
Brush.verticalGradient(
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
),
)
internal val mobileSurface = Color(0xFFF6F7FA)
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
internal val mobileBorder = Color(0xFFE5E7EC)
internal val mobileBorderStrong = Color(0xFFD6DAE2)
internal val mobileText = Color(0xFF17181C)
internal val mobileTextSecondary = Color(0xFF5D6472)
internal val mobileTextTertiary = Color(0xFF99A0AE)
internal val mobileAccent = Color(0xFF1D5DD8)
internal val mobileAccentSoft = Color(0xFFECF3FF)
internal val mobileSuccess = Color(0xFF2F8C5A)
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
internal val mobileWarning = Color(0xFFC8841A)
internal val mobileWarningSoft = Color(0xFFFFF8EC)
internal val mobileDanger = Color(0xFFD04B4B)
internal val mobileDangerSoft = Color(0xFFFFF2F2)
internal val mobileCodeBg = Color(0xFF15171B)
internal val mobileCodeText = Color(0xFFE8EAEE)
internal val mobileFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileTitle1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
internal val mobileTitle2 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = (-0.3).sp,
)
internal val mobileHeadline =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
internal val mobileBody =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
internal val mobileCallout =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
internal val mobileCaption1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
internal val mobileCaption2 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,326 @@
package ai.openclaw.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ai.openclaw.android.MainViewModel
private enum class HomeTab(
val label: String,
val icon: ImageVector,
) {
Connect(label = "Connect", icon = Icons.Default.CheckCircle),
Chat(label = "Chat", icon = Icons.Default.ChatBubble),
Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver),
Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare),
Settings(label = "Settings", icon = Icons.Default.Settings),
}
private enum class StatusVisual {
Connected,
Connecting,
Warning,
Error,
Offline,
}
@Composable
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
// Stop TTS when user navigates away from voice tab
LaunchedEffect(activeTab) {
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
}
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val statusVisual =
remember(statusText, isConnected) {
val lower = statusText.lowercase()
when {
isConnected -> StatusVisual.Connected
lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting
lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning
lower.contains("error") || lower.contains("failed") -> StatusVisual.Error
else -> StatusVisual.Offline
}
}
val density = LocalDensity.current
val imeVisible = WindowInsets.ime.getBottom(density) > 0
val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible
Scaffold(
modifier = modifier,
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TopStatusBar(
statusText = statusText,
statusVisual = statusVisual,
)
},
bottomBar = {
if (!hideBottomTabBar) {
BottomTabBar(
activeTab = activeTab,
onSelect = { activeTab = it },
)
}
},
) { innerPadding ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.background(mobileBackgroundGradient),
) {
when (activeTab) {
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
@Composable
private fun ScreenTabScreen(viewModel: MainViewModel) {
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val canvasUrl by viewModel.canvasCurrentUrl.collectAsState()
val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState()
val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState()
val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true
val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated))
val restoreCtaText =
when {
canvasRehydratePending -> "Restore requested. Waiting for agent…"
!canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!!
else -> "Canvas reset. Tap to restore dashboard."
}
Box(modifier = Modifier.fillMaxSize()) {
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
if (showRestoreCta) {
Surface(
onClick = {
if (canvasRehydratePending) return@Surface
viewModel.requestCanvasRehydrate(source = "screen_tab_cta")
},
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp),
shape = RoundedCornerShape(12.dp),
color = mobileSurface.copy(alpha = 0.9f),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 4.dp,
) {
Text(
text = restoreCtaText,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout.copy(fontWeight = FontWeight.Medium),
color = mobileText,
)
}
}
}
}
@Composable
private fun TopStatusBar(
statusText: String,
statusVisual: StatusVisual,
) {
val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val (chipBg, chipDot, chipText, chipBorder) =
when (statusVisual) {
StatusVisual.Connected ->
listOf(
mobileSuccessSoft,
mobileSuccess,
mobileSuccess,
Color(0xFFCFEBD8),
)
StatusVisual.Connecting ->
listOf(
mobileAccentSoft,
mobileAccent,
mobileAccent,
Color(0xFFD5E2FA),
)
StatusVisual.Warning ->
listOf(
mobileWarningSoft,
mobileWarning,
mobileWarning,
Color(0xFFEED8B8),
)
StatusVisual.Error ->
listOf(
mobileDangerSoft,
mobileDanger,
mobileDanger,
Color(0xFFF3C8C8),
)
StatusVisual.Offline ->
listOf(
mobileSurface,
mobileTextTertiary,
mobileTextSecondary,
mobileBorder,
)
}
Surface(
modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets),
color = Color.Transparent,
shadowElevation = 0.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "OpenClaw",
style = mobileTitle2,
color = mobileText,
)
Surface(
shape = RoundedCornerShape(999.dp),
color = chipBg,
border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.padding(top = 1.dp),
color = chipDot,
shape = RoundedCornerShape(999.dp),
) {
Box(modifier = Modifier.padding(4.dp))
}
Text(
text = statusText.trim().ifEmpty { "Offline" },
style = mobileCaption1,
color = chipText,
maxLines = 1,
)
}
}
}
}
}
@Composable
private fun BottomTabBar(
activeTab: HomeTab,
onSelect: (HomeTab) -> Unit,
) {
val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal)
Box(
modifier =
Modifier
.fillMaxWidth(),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(safeInsets)
.padding(horizontal = 10.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeTab.entries.forEach { tab ->
val active = tab == activeTab
Surface(
onClick = { onSelect(tab) },
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
shape = RoundedCornerShape(16.dp),
color = if (active) mobileAccentSoft else Color.Transparent,
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
tint = if (active) mobileAccent else mobileTextTertiary,
)
Text(
text = tab.label,
color = if (active) mobileAccent else mobileTextSecondary,
style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium),
)
}
}
}
}
}
}
}

View File

@@ -1,429 +1,20 @@
package ai.openclaw.android.ui
import android.annotation.SuppressLint
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.util.Log
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import ai.openclaw.android.CameraHudKind
import ai.openclaw.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current
val serverName by viewModel.serverName.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val cameraHud by viewModel.cameraHud.collectAsState()
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
val screenRecordActive by viewModel.screenRecordActive.collectAsState()
val isForeground by viewModel.isForeground.collectAsState()
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
val talkEnabled by viewModel.talkEnabled.collectAsState()
val talkStatusText by viewModel.talkStatusText.collectAsState()
val talkIsListening by viewModel.talkIsListening.collectAsState()
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) viewModel.setTalkEnabled(true)
}
val activity =
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if (!isForeground) {
return@remember StatusActivity(
title = "Foreground required",
icon = Icons.Default.Report,
contentDescription = "Foreground required",
)
}
val onboardingCompleted by viewModel.onboardingCompleted.collectAsState()
val lowerStatus = statusText.lowercase()
if (lowerStatus.contains("repair")) {
return@remember StatusActivity(
title = "Repairing…",
icon = Icons.Default.Refresh,
contentDescription = "Repairing",
)
}
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
return@remember StatusActivity(
title = "Approval pending",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Approval pending",
)
}
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
if (screenRecordActive) {
return@remember StatusActivity(
title = "Recording screen…",
icon = Icons.AutoMirrored.Filled.ScreenShare,
contentDescription = "Recording screen",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
cameraHud?.let { hud ->
return@remember when (hud.kind) {
CameraHudKind.Photo ->
StatusActivity(
title = hud.message,
icon = Icons.Default.PhotoCamera,
contentDescription = "Taking photo",
)
CameraHudKind.Recording ->
StatusActivity(
title = hud.message,
icon = Icons.Default.FiberManualRecord,
contentDescription = "Recording",
tint = androidx.compose.ui.graphics.Color.Red,
)
CameraHudKind.Success ->
StatusActivity(
title = hud.message,
icon = Icons.Default.CheckCircle,
contentDescription = "Capture finished",
)
CameraHudKind.Error ->
StatusActivity(
title = hud.message,
icon = Icons.Default.Error,
contentDescription = "Capture failed",
tint = androidx.compose.ui.graphics.Color.Red,
)
}
}
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
return@remember StatusActivity(
title = "Mic permission",
icon = Icons.Default.Error,
contentDescription = "Mic permission required",
)
}
if (voiceWakeStatusText == "Paused") {
val suffix = if (!isForeground) " (background)" else ""
return@remember StatusActivity(
title = "Voice Wake paused$suffix",
icon = Icons.Default.RecordVoiceOver,
contentDescription = "Voice Wake paused",
)
}
null
}
val gatewayState =
remember(serverName, statusText) {
when {
serverName != null -> GatewayState.Connected
statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting
statusText.contains("error", ignoreCase = true) -> GatewayState.Error
else -> GatewayState.Disconnected
}
}
val voiceEnabled =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
if (!onboardingCompleted) {
OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize())
return
}
// Camera flash must be in a Popup to render above the WebView.
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
}
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
StatusPill(
gateway = gatewayState,
voiceEnabled = voiceEnabled,
activity = activity,
onClick = { sheet = Sheet.Settings },
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
)
}
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
Column(
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.End,
) {
OverlayIconButton(
onClick = { sheet = Sheet.Chat },
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
)
// Talk mode gets a dedicated side bubble instead of burying it in settings.
val baseOverlay = overlayContainerColor()
val talkContainer =
lerp(
baseOverlay,
seamColor.copy(alpha = baseOverlay.alpha),
if (talkEnabled) 0.35f else 0.22f,
)
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
OverlayIconButton(
onClick = {
val next = !talkEnabled
if (next) {
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
viewModel.setTalkEnabled(true)
} else {
viewModel.setTalkEnabled(false)
}
},
containerColor = talkContainer,
contentColor = talkContent,
icon = {
Icon(
Icons.Default.RecordVoiceOver,
contentDescription = "Talk Mode",
)
},
)
OverlayIconButton(
onClick = { sheet = Sheet.Settings },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
)
}
}
if (talkEnabled) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
)
}
}
val currentSheet = sheet
if (currentSheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (currentSheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@Composable
private fun OverlayIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
containerColor: ComposeColor? = null,
contentColor: ComposeColor? = null,
) {
FilledTonalIconButton(
onClick = onClick,
modifier = Modifier.size(44.dp),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = containerColor ?: overlayContainerColor(),
contentColor = contentColor ?: overlayIconColor(),
),
) {
icon()
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
} else {
disableForceDarkIfSupported(settings)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable) return
if (!request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
// Use default layer/background; avoid forcing a black fill over WebView content.
val a2uiBridge =
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
}
},
)
}
private fun disableForceDarkIfSupported(settings: WebSettings) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
}
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}

View File

@@ -1,114 +0,0 @@
package ai.openclaw.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun StatusPill(
gateway: GatewayState,
voiceEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
activity: StatusActivity? = null,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = overlayContainerColor(),
tonalElevation = 3.dp,
shadowElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(9.dp),
shape = CircleShape,
color = gateway.color,
) {}
Text(
text = gateway.title,
style = MaterialTheme.typography.labelLarge,
)
}
VerticalDivider(
modifier = Modifier.height(14.dp).alpha(0.35f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (activity != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = activity.icon,
contentDescription = activity.contentDescription,
tint = activity.tint ?: overlayIconColor(),
modifier = Modifier.size(18.dp),
)
Text(
text = activity.title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
} else {
Icon(
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
tint =
if (voiceEnabled) {
overlayIconColor()
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(2.dp))
}
}
}
data class StatusActivity(
val title: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val tint: Color? = null,
)
enum class GatewayState(val title: String, val color: Color) {
Connected("Connected", Color(0xFF2ECC71)),
Connecting("Connecting…", Color(0xFFF1C40F)),
Error("Error", Color(0xFFE74C3C)),
Disconnected("Offline", Color(0xFF9E9E9E)),
}

View File

@@ -0,0 +1,422 @@
package ai.openclaw.android.ui
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.voice.VoiceConversationEntry
import ai.openclaw.android.voice.VoiceConversationRole
import kotlin.math.max
@Composable
fun VoiceTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val activity = remember(context) { context.findActivity() }
val listState = rememberLazyListState()
val gatewayStatus by viewModel.statusText.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val micCooldown by viewModel.micCooldown.collectAsState()
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
val micStatusText by viewModel.micStatusText.collectAsState()
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
val micConversation by viewModel.micConversation.collectAsState()
val micInputLevel by viewModel.micInputLevel.collectAsState()
val micIsSending by viewModel.micIsSending.collectAsState()
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
var pendingMicEnable by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasMicPermission = context.hasRecordAudioPermission()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// Stop TTS when leaving the voice screen
viewModel.setVoiceScreenActive(false)
}
}
val requestMicPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPermission = granted
if (granted && pendingMicEnable) {
viewModel.setMicEnabled(true)
}
pendingMicEnable = false
}
LaunchedEffect(micConversation.size, showThinkingBubble) {
val total = micConversation.size + if (showThinkingBubble) 1 else 0
if (total > 0) {
listState.animateScrollToItem(total - 1)
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.background(mobileBackgroundGradient)
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (micConversation.isEmpty() && !showThinkingBubble) {
item {
Box(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = mobileTextTertiary,
)
Text(
"Tap the mic to start",
style = mobileHeadline,
color = mobileTextSecondary,
)
Text(
"Each pause sends a turn automatically.",
style = mobileCallout,
color = mobileTextTertiary,
)
}
}
}
}
items(items = micConversation, key = { it.id }) { entry ->
VoiceTurnBubble(entry = entry)
}
if (showThinkingBubble) {
item {
VoiceThinkingBubble()
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (!micLiveTranscript.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
) {
Text(
micLiveTranscript!!.trim(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout,
color = mobileText,
)
}
}
// Mic button with input-reactive ring + speaker toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Speaker toggle
IconButton(
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
modifier = Modifier.size(48.dp),
colors =
IconButtonDefaults.iconButtonColors(
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
),
) {
Icon(
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
modifier = Modifier.size(22.dp),
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
)
}
// Ring size = 68dp base + up to 22dp driven by mic input level.
// The outer Box is fixed at 90dp (max ring size) so the ring never shifts the button.
Box(
modifier = Modifier.padding(horizontal = 16.dp).size(90.dp),
contentAlignment = Alignment.Center,
) {
if (micEnabled) {
val ringLevel = micInputLevel.coerceIn(0f, 1f)
val ringSize = 68.dp + (22.dp * max(ringLevel, 0.05f))
Box(
modifier =
Modifier
.size(ringSize)
.background(mobileAccent.copy(alpha = 0.12f + 0.14f * ringLevel), CircleShape),
)
}
Button(
onClick = {
if (micCooldown) return@Button
if (micEnabled) {
viewModel.setMicEnabled(false)
return@Button
}
if (hasMicPermission) {
viewModel.setMicEnabled(true)
} else {
pendingMicEnable = true
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
enabled = !micCooldown,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(60.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (micCooldown) mobileTextSecondary else if (micEnabled) mobileDanger else mobileAccent,
contentColor = Color.White,
disabledContainerColor = mobileTextSecondary,
disabledContentColor = Color.White.copy(alpha = 0.5f),
),
) {
Icon(
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
modifier = Modifier.size(24.dp),
)
}
}
// Invisible spacer to balance the row (same size as speaker button)
Box(modifier = Modifier.size(48.dp))
}
// Status + labels
val queueCount = micQueuedMessages.size
val stateText =
when {
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micCooldown -> "Cooldown"
micEnabled -> "Listening"
else -> "Mic off"
}
Text(
"$gatewayStatus · $stateText",
style = mobileCaption1,
color = mobileTextSecondary,
)
if (!hasMicPermission) {
val showRationale =
if (activity == null) {
false
} else {
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
}
Text(
if (showRationale) {
"Microphone permission is required for voice mode."
} else {
"Microphone blocked. Open app settings to enable it."
},
style = mobileCaption1,
color = mobileWarning,
textAlign = TextAlign.Center,
)
Button(
onClick = { openAppSettings(context) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
) {
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
}
}
}
}
}
@Composable
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
val isUser = entry.role == VoiceConversationRole.User
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(12.dp),
color = if (isUser) mobileAccentSoft else Color.White,
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 11.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
if (isUser) "You" else "OpenClaw",
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = if (isUser) mobileAccent else mobileTextSecondary,
)
Text(
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
style = mobileCallout,
color = mobileText,
)
}
}
}
}
@Composable
private fun VoiceThinkingBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(12.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ThinkingDots(color = mobileTextSecondary)
Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary)
}
}
}
}
@Composable
private fun ThinkingDots(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
ThinkingDot(alpha = 0.38f, color = color)
ThinkingDot(alpha = 0.62f, color = color)
ThinkingDot(alpha = 0.90f, color = color)
}
}
@Composable
private fun ThinkingDot(alpha: Float, color: Color) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = color,
) {}
}
private fun Context.hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private fun openAppSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}

View File

@@ -0,0 +1,42 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal data class Base64ImageState(
val image: ImageBitmap?,
val failed: Boolean,
)
@Composable
internal fun rememberBase64ImageState(base64: String): Base64ImageState {
var image by remember(base64) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
}
}
if (image == null) failed = true
}
return Base64ImageState(image = image, failed = failed)
}

View File

@@ -1,31 +1,36 @@
package ai.openclaw.android.ui.chat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,149 +42,168 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatSessionEntry
import androidx.compose.ui.unit.sp
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileAccentSoft
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileSurface
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileTextTertiary
@Composable
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
errorText: String?,
attachments: List<PendingImageAttachment>,
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit,
onAbort: () -> Unit,
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel = friendlySessionName(
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
)
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val sendBusy = pendingRunCount > 0
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
FilledTonalButton(
onClick = { showSessionMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1f)) {
Surface(
onClick = { showThinkingMenu = true },
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
DropdownMenuItem(
text = { Text(friendlySessionName(entry.displayName ?: entry.key)) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) {
Text("")
} else {
Spacer(modifier = Modifier.width(10.dp))
}
},
)
}
Text(
text = "Thinking: ${thinkingLabel(thinkingLevel)}",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileText,
)
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary)
}
}
Box {
FilledTonalButton(
onClick = { showThinkingMenu = true },
contentPadding = ButtonDefaults.ContentPadding,
) {
Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1)
}
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
}
}
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
}
}
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Message OpenClaw…") },
minLines = 2,
maxLines = 6,
SecondaryActionButton(
label = "Attach",
icon = Icons.Default.AttachFile,
enabled = true,
onClick = onPickImages,
)
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
Spacer(modifier = Modifier.weight(1f))
if (attachments.isNotEmpty()) {
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
if (pendingRunCount > 0) {
FilledTonalIconButton(
onClick = onAbort,
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0x33E74C3C),
contentColor = Color(0xFFE74C3C),
),
) {
Icon(Icons.Default.Stop, contentDescription = "Abort")
}
} else {
FilledTonalIconButton(onClick = {
val text = input
input = ""
onSend(text)
}, enabled = canSend) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
}
}
HorizontalDivider(color = mobileBorder)
Text(
text = "MESSAGE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp),
color = mobileTextSecondary,
)
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth().height(92.dp),
placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) },
minLines = 2,
maxLines = 5,
textStyle = mobileBodyStyle().copy(color = mobileText),
shape = RoundedCornerShape(14.dp),
colors = chatTextFieldColors(),
)
if (!healthOk) {
Text(
text = "Gateway is offline. Connect first in the Connect tab.",
style = mobileCallout,
color = ai.openclaw.android.ui.mobileWarning,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryActionButton(
label = "Refresh",
icon = Icons.Default.Refresh,
enabled = true,
compact = true,
onClick = onRefresh,
)
SecondaryActionButton(
label = "Abort",
icon = Icons.Default.Stop,
enabled = pendingRunCount > 0,
compact = true,
onClick = onAbort,
)
}
if (!errorText.isNullOrBlank()) {
Button(
onClick = {
val text = input
input = ""
onSend(text)
},
enabled = canSend,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = mobileAccent,
contentColor = Color.White,
disabledContainerColor = mobileBorderStrong,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
) {
if (sendBusy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = errorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
text = "Send",
style = mobileHeadline.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -187,26 +211,35 @@ fun ChatComposer(
}
@Composable
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
private fun SecondaryActionButton(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
enabled: Boolean,
compact: Boolean = false,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = if (compact) Modifier.size(44.dp) else Modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = mobileTextSecondary,
disabledContainerColor = Color.White,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, mobileBorderStrong),
contentPadding = if (compact) PaddingValues(0.dp) else ButtonDefaults.ContentPadding,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(7.dp),
shape = androidx.compose.foundation.shape.CircleShape,
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
) {}
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp))
if (!compact) {
Spacer(modifier = Modifier.width(5.dp))
Text(
if (healthOk) "Connected" else "Connecting…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = label,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = if (enabled) mobileTextSecondary else mobileTextTertiary,
)
}
}
@@ -220,14 +253,14 @@ private fun ThinkingMenuItem(
onDismiss: () -> Unit,
) {
DropdownMenuItem(
text = { Text(thinkingLabel(value)) },
text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) },
onClick = {
onSet(value)
onDismiss()
},
trailingIcon = {
if (value == current.trim().lowercase()) {
Text("")
Text("", style = mobileCallout, color = mobileAccent)
} else {
Spacer(modifier = Modifier.width(10.dp))
}
@@ -266,20 +299,55 @@ private fun AttachmentsStrip(
private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
FilledTonalIconButton(
Text(
text = fileName,
style = mobileCaption1,
color = mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Surface(
onClick = onRemove,
modifier = Modifier.size(30.dp),
shape = RoundedCornerShape(999.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Text("×")
Text(
text = "×",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold),
color = mobileTextSecondary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
}
}
@Composable
private fun chatTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = mobileSurface,
unfocusedContainerColor = mobileSurface,
focusedBorderColor = mobileAccent,
unfocusedBorderColor = mobileBorder,
focusedTextColor = mobileText,
unfocusedTextColor = mobileText,
cursorColor = mobileAccent,
)
@Composable
private fun mobileBodyStyle() =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = ai.openclaw.android.ui.mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)

View File

@@ -1,201 +1,553 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.unit.sp
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileTextSecondary
import org.commonmark.Extension
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableBlock
import org.commonmark.ext.gfm.tables.TableBody
import org.commonmark.ext.gfm.tables.TableCell
import org.commonmark.ext.gfm.tables.TableHead
import org.commonmark.ext.gfm.tables.TableRow
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.ext.task.list.items.TaskListItemMarker
import org.commonmark.ext.task.list.items.TaskListItemsExtension
import org.commonmark.node.BlockQuote
import org.commonmark.node.BulletList
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.Heading
import org.commonmark.node.HardLineBreak
import org.commonmark.node.HtmlBlock
import org.commonmark.node.HtmlInline
import org.commonmark.node.Image as MarkdownImage
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Link
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text as MarkdownTextNode
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
private const val LIST_INDENT_DP = 14
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
private val markdownParser: Parser by lazy {
val extensions: List<Extension> =
listOf(
AutolinkExtension.create(),
StrikethroughExtension.create(),
TablesExtension.create(),
TaskListItemsExtension.create(),
)
Parser.builder()
.extensions(extensions)
.build()
}
@Composable
fun ChatMarkdown(text: String, textColor: Color) {
val blocks = remember(text) { splitMarkdown(text) }
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
val document = remember(text) { markdownParser.parse(text) as Document }
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (b in blocks) {
when (b) {
is ChatMarkdownBlock.Text -> {
val trimmed = b.text.trimEnd()
if (trimmed.isEmpty()) continue
Text(
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
}
is ChatMarkdownBlock.Code -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = b.code, language = b.language)
}
}
is ChatMarkdownBlock.InlineImage -> {
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
}
}
}
RenderMarkdownBlocks(
start = document.firstChild,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = 0,
)
}
}
private sealed interface ChatMarkdownBlock {
data class Text(val text: String) : ChatMarkdownBlock
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
}
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
if (raw.isEmpty()) return emptyList()
val out = ArrayList<ChatMarkdownBlock>()
var idx = 0
while (idx < raw.length) {
val fenceStart = raw.indexOf("```", startIndex = idx)
if (fenceStart < 0) {
out.addAll(splitInlineImages(raw.substring(idx)))
break
}
if (fenceStart > idx) {
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
}
val langLineStart = fenceStart + 3
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
if (fenceEnd < 0) {
out.addAll(splitInlineImages(raw.substring(fenceStart)))
break
}
val code = raw.substring(codeStart, fenceEnd)
out.add(ChatMarkdownBlock.Code(code = code, language = language))
idx = fenceEnd + 3
}
return out
}
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
if (text.isEmpty()) return emptyList()
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
val out = ArrayList<ChatMarkdownBlock>()
var idx = 0
while (idx < text.length) {
val m = regex.find(text, startIndex = idx) ?: break
val start = m.range.first
val end = m.range.last + 1
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
if (b64.isNotEmpty()) {
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
}
idx = end
}
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
return out
}
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
if (text.isEmpty()) return AnnotatedString("")
val out = buildAnnotatedString {
var i = 0
while (i < text.length) {
if (text.startsWith("**", startIndex = i)) {
val end = text.indexOf("**", startIndex = i + 2)
if (end > i + 2) {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(text.substring(i + 2, end))
}
i = end + 2
continue
}
}
if (text[i] == '`') {
val end = text.indexOf('`', startIndex = i + 1)
if (end > i + 1) {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = inlineCodeBg,
),
) {
append(text.substring(i + 1, end))
}
i = end + 1
continue
}
}
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
val end = text.indexOf('*', startIndex = i + 1)
if (end > i + 1) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(text.substring(i + 1, end))
}
i = end + 1
continue
}
}
append(text[i])
i += 1
}
}
return out
}
@Composable
private fun InlineBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
private fun RenderMarkdownBlocks(
start: Node?,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
var node = start
while (node != null) {
val current = node
when (current) {
is Paragraph -> {
RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles)
}
is Heading -> {
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
Text(
text = headingText,
style = headingStyle(current.level),
color = textColor,
)
}
is FencedCodeBlock -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null })
}
}
if (image == null) failed = true
is IndentedCodeBlock -> {
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
ChatCodeBlock(code = current.literal.orEmpty(), language = null)
}
}
is BlockQuote -> {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.background(mobileTextSecondary.copy(alpha = 0.35f)),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RenderMarkdownBlocks(
start = current.firstChild,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
}
}
is BulletList -> {
RenderBulletList(
list = current,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
is OrderedList -> {
RenderOrderedList(
list = current,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
is TableBlock -> {
RenderTableBlock(
table = current,
textColor = textColor,
inlineStyles = inlineStyles,
)
}
is ThematicBreak -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(mobileTextSecondary.copy(alpha = 0.25f)),
)
}
is HtmlBlock -> {
val literal = current.literal.orEmpty().trim()
if (literal.isNotEmpty()) {
Text(
text = literal,
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
color = textColor,
)
}
}
}
node = current.next
}
}
@Composable
private fun RenderParagraph(
paragraph: Paragraph,
textColor: Color,
inlineStyles: InlineStyles,
) {
val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) }
if (standaloneImage != null) {
InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType)
return
}
val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) }
if (annotated.text.trimEnd().isEmpty()) {
return
}
Text(
text = annotated,
style = mobileCallout,
color = textColor,
)
}
@Composable
private fun RenderBulletList(
list: BulletList,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
Column(
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
var item = list.firstChild
while (item != null) {
if (item is ListItem) {
RenderListItem(
item = item,
markerText = "",
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
}
item = item.next
}
}
}
@Composable
private fun RenderOrderedList(
list: OrderedList,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
Column(
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
var index = list.markerStartNumber ?: 1
var item = list.firstChild
while (item != null) {
if (item is ListItem) {
RenderListItem(
item = item,
markerText = "$index.",
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth,
)
index += 1
}
item = item.next
}
}
}
@Composable
private fun RenderListItem(
item: ListItem,
markerText: String,
textColor: Color,
inlineStyles: InlineStyles,
listDepth: Int,
) {
var contentStart = item.firstChild
var marker = markerText
val task = contentStart as? TaskListItemMarker
if (task != null) {
marker = if (task.isChecked) "" else ""
contentStart = task.next
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = marker,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
modifier = Modifier.width(24.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
RenderMarkdownBlocks(
start = contentStart,
textColor = textColor,
inlineStyles = inlineStyles,
listDepth = listDepth + 1,
)
}
}
}
@Composable
private fun RenderTableBlock(
table: TableBlock,
textColor: Color,
inlineStyles: InlineStyles,
) {
val rows = remember(table) { buildTableRows(table, inlineStyles) }
if (rows.isEmpty()) return
val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scrollState)
.border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)),
) {
for (row in rows) {
Row(
modifier = Modifier.fillMaxWidth(),
) {
for (index in 0 until maxCols) {
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text(
text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
color = textColor,
modifier = Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
.padding(horizontal = 8.dp, vertical = 6.dp)
.width(160.dp),
)
}
}
}
}
}
private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List<TableRenderRow> {
val rows = mutableListOf<TableRenderRow>()
var child = table.firstChild
while (child != null) {
when (child) {
is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles))
is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles))
is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles))
}
child = child.next
}
return rows
}
private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List<TableRenderRow> {
val rows = mutableListOf<TableRenderRow>()
var row = section.firstChild
while (row != null) {
if (row is TableRow) {
rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles))
}
row = row.next
}
return rows
}
private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow {
val cells = mutableListOf<AnnotatedString>()
var cellNode = row.firstChild
while (cellNode != null) {
if (cellNode is TableCell) {
cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles))
}
cellNode = cellNode.next
}
return TableRenderRow(isHeader = isHeader, cells = cells)
}
private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString {
return buildAnnotatedString {
appendInlineNode(
node = start,
inlineCodeBg = inlineStyles.inlineCodeBg,
inlineCodeColor = inlineStyles.inlineCodeColor,
)
}
}
private fun AnnotatedString.Builder.appendInlineNode(
node: Node?,
inlineCodeBg: Color,
inlineCodeColor: Color,
) {
var current = node
while (current != null) {
when (current) {
is MarkdownTextNode -> append(current.literal)
is SoftLineBreak -> append('\n')
is HardLineBreak -> append('\n')
is Code -> {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = inlineCodeBg,
color = inlineCodeColor,
),
) {
append(current.literal)
}
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Strikethrough -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is Link -> {
withStyle(
SpanStyle(
color = mobileAccent,
textDecoration = TextDecoration.Underline,
),
) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
is MarkdownImage -> {
val alt = buildPlainText(current.firstChild)
if (alt.isNotBlank()) {
append(alt)
} else {
append("image")
}
}
is HtmlInline -> {
if (!current.literal.isNullOrBlank()) {
append(current.literal)
}
}
else -> {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
}
}
current = current.next
}
}
private fun buildPlainText(start: Node?): String {
val sb = StringBuilder()
var node = start
while (node != null) {
when (node) {
is MarkdownTextNode -> sb.append(node.literal)
is SoftLineBreak, is HardLineBreak -> sb.append('\n')
else -> sb.append(buildPlainText(node.firstChild))
}
node = node.next
}
return sb.toString()
}
private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
val only = paragraph.firstChild as? MarkdownImage ?: return null
if (only.next != null) return null
return parseDataImageDestination(only.destination)
}
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png"
val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
if (base64.isEmpty()) return null
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
private fun headingStyle(level: Int): TextStyle {
return when (level.coerceIn(1, 6)) {
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
}
}
private data class InlineStyles(
val inlineCodeBg: Color,
val inlineCodeColor: Color,
)
private data class TableRenderRow(
val isHeader: Boolean,
val cells: List<AnnotatedString>,
)
private data class ParsedDataImage(
val mimeType: String,
val base64: String,
)
@Composable
private fun InlineBase64Image(base64: String, mimeType: String?) {
val imageState = rememberBase64ImageState(base64)
val image = imageState.image
if (image != null) {
Image(
@@ -204,12 +556,12 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
} else if (failed) {
} else if (imageState.failed) {
Text(
text = "Image unavailable",
modifier = Modifier.padding(vertical = 2.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = mobileCaption1,
color = mobileTextSecondary,
)
}
}

View File

@@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowCircleDown
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
@Composable
fun ChatMessageListCard(
@@ -29,6 +29,7 @@ fun ChatMessageListCard(
pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
healthOk: Boolean,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
@@ -38,73 +39,70 @@ fun ChatMessageListCard(
listState.animateScrollToItem(index = 0)
}
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp),
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
}
}
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center))
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
}
}
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk)
}
}
}
@Composable
private fun EmptyChatHint(modifier: Modifier = Modifier) {
Row(
modifier = modifier.alpha(0.7f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
) {
Icon(
imageVector = Icons.Default.ArrowCircleDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Message OpenClaw…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("No messages yet", style = mobileHeadline, color = mobileText)
Text(
text =
if (healthOk) {
"Send the first prompt to start this session."
} else {
"Connect gateway first, then return to chat."
},
style = mobileCallout,
color = mobileTextSecondary,
)
}
}
}

View File

@@ -1,8 +1,7 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,67 +11,97 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.ui.unit.sp
import ai.openclaw.android.chat.ChatMessage
import ai.openclaw.android.chat.ChatMessageContent
import ai.openclaw.android.chat.ChatPendingToolCall
import ai.openclaw.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileAccentSoft
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileHeadline
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.util.Locale
private data class ChatBubbleStyle(
val alignEnd: Boolean,
val containerColor: Color,
val borderColor: Color,
val roleColor: Color,
)
@Composable
fun ChatMessageBubble(message: ChatMessage) {
val isUser = message.role.lowercase() == "user"
val role = message.role.trim().lowercase(Locale.US)
val style = bubbleStyle(role)
// Filter to only displayable content parts (text with content, or base64 images)
val displayableContent = message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
// Filter to only displayable content parts (text with content, or base64 images).
val displayableContent =
message.content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
}
}
// Skip rendering entirely if no displayable content
if (displayableContent.isEmpty()) return
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
ChatMessageBody(content = displayableContent, textColor = mobileText)
}
}
@Composable
private fun ChatBubbleContainer(
style: ChatBubbleStyle,
roleLabel: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, style.borderColor),
color = style.containerColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
color = Color.Transparent,
modifier = Modifier.fillMaxWidth(0.92f),
modifier = Modifier.fillMaxWidth(0.90f),
) {
Box(
modifier =
Modifier
.background(bubbleBackground(isUser))
.padding(horizontal = 12.dp, vertical = 10.dp),
Column(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
val textColor = textColorOverBubble(isUser)
ChatMessageBody(content = displayableContent, textColor = textColor)
Text(
text = roleLabel,
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = style.roleColor,
)
content()
}
}
}
@@ -80,7 +109,7 @@ fun ChatMessageBubble(message: ChatMessage) {
@Composable
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (part in content) {
when (part.type) {
"text" -> {
@@ -98,19 +127,16 @@ private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color)
@Composable
fun ChatTypingIndicatorBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = roleLabel("assistant"),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
DotPulse()
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
DotPulse(color = mobileTextSecondary)
Text("Thinking...", style = mobileCallout, color = mobileTextSecondary)
}
}
}
@@ -122,38 +148,37 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Running tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = "TOOLS",
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = mobileCallout,
color = mobileTextSecondary,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
detail,
style = mobileCaption1,
color = mobileTextSecondary,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (toolCalls.size > 6) {
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (toolCalls.size > 6) {
Text(
text = "... +${toolCalls.size - 6} more",
style = mobileCaption1,
color = mobileTextSecondary,
)
}
}
}
@@ -161,103 +186,114 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
@Composable
fun ChatStreamingAssistantBubble(text: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
}
}
ChatBubbleContainer(
style = bubbleStyle("assistant").copy(borderColor = mobileAccent),
roleLabel = "ASSISTANT · LIVE",
) {
ChatMarkdown(text = text, textColor = mobileText)
}
}
@Composable
private fun bubbleBackground(isUser: Boolean): Brush {
return if (isUser) {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
)
} else {
Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
)
private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) {
"user" ->
ChatBubbleStyle(
alignEnd = true,
containerColor = mobileAccentSoft,
borderColor = mobileAccent,
roleColor = mobileAccent,
)
"system" ->
ChatBubbleStyle(
alignEnd = false,
containerColor = mobileWarningSoft,
borderColor = mobileWarning.copy(alpha = 0.45f),
roleColor = mobileWarning,
)
else ->
ChatBubbleStyle(
alignEnd = false,
containerColor = Color.White,
borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary,
)
}
}
@Composable
private fun textColorOverBubble(isUser: Boolean): Color {
return if (isUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
private fun roleLabel(role: String): String {
return when (role) {
"user" -> "USER"
"system" -> "SYSTEM"
else -> "ASSISTANT"
}
}
@Composable
private fun ChatBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
}
}
if (image == null) failed = true
}
val imageState = rememberBase64ImageState(base64)
val image = imageState.image
if (image != null) {
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = Color.White,
modifier = Modifier.fillMaxWidth(),
)
} else if (failed) {
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
) {
Image(
bitmap = image!!,
contentDescription = mimeType ?: "attachment",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
}
} else if (imageState.failed) {
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
}
}
@Composable
private fun DotPulse() {
private fun DotPulse(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
PulseDot(alpha = 0.38f)
PulseDot(alpha = 0.62f)
PulseDot(alpha = 0.90f)
PulseDot(alpha = 0.38f, color = color)
PulseDot(alpha = 0.62f, color = color)
PulseDot(alpha = 0.90f, color = color)
}
}
@Composable
private fun PulseDot(alpha: Float) {
private fun PulseDot(alpha: Float, color: Color) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = color,
) {}
}
@Composable
fun ChatCodeBlock(code: String, language: String?) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = code.trimEnd(),
modifier = Modifier.padding(10.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(Locale.US),
style = mobileCaption2.copy(letterSpacing = 0.4.sp),
color = mobileTextSecondary,
)
}
Text(
text = code.trimEnd(),
fontFamily = FontFamily.Monospace,
style = mobileCallout,
color = mobileCodeText,
)
}
}
}

View File

@@ -1,92 +0,0 @@
package ai.openclaw.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ai.openclaw.android.chat.ChatSessionEntry
@Composable
fun ChatSessionsDialog(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onSelect: (sessionKey: String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {},
title = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Sessions", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
},
text = {
if (sessions.isEmpty()) {
Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(sessions, key = { it.key }) { entry ->
SessionRow(
entry = entry,
isCurrent = entry.key == currentSessionKey,
onClick = { onSelect(entry.key) },
)
}
}
}
},
)
}
@Composable
private fun SessionRow(
entry: ChatSessionEntry,
isCurrent: Boolean,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color =
if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceContainer
},
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.weight(1f))
if (isCurrent) {
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

View File

@@ -5,10 +5,19 @@ import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -17,10 +26,28 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.chat.ChatSessionEntry
import ai.openclaw.android.chat.OutgoingAttachment
import ai.openclaw.android.ui.mobileAccent
import ai.openclaw.android.ui.mobileBorder
import ai.openclaw.android.ui.mobileBorderStrong
import ai.openclaw.android.ui.mobileCallout
import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCaption2
import ai.openclaw.android.ui.mobileDanger
import ai.openclaw.android.ui.mobileSuccess
import ai.openclaw.android.ui.mobileSuccessSoft
import ai.openclaw.android.ui.mobileText
import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -72,52 +99,160 @@ fun ChatSheetContent(viewModel: MainViewModel) {
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ChatThreadSelector(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
onSelectSession = { key -> viewModel.switchChatSession(key) },
)
if (!errorText.isNullOrBlank()) {
ChatErrorRail(errorText = errorText!!)
}
ChatMessageListCard(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
modifier = Modifier.weight(1f, fill = true),
)
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
errorText = errorText,
attachments = attachments,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onSelectSession = { key -> viewModel.switchChatSession(key) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
attachments.map { att ->
OutgoingAttachment(
type = "image",
mimeType = att.mimeType,
fileName = att.fileName,
base64 = att.base64,
)
}
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
attachments.clear()
},
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
ChatComposer(
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
attachments = attachments,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing =
attachments.map { att ->
OutgoingAttachment(
type = "image",
mimeType = att.mimeType,
fileName = att.fileName,
base64 = att.base64,
)
}
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
attachments.clear()
},
)
}
}
}
@Composable
private fun ChatThreadSelector(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
onSelectSession: (String) -> Unit,
) {
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey)
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(
text = "SESSION",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = mobileTextSecondary,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text(
text = currentSessionLabel,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ChatConnectionPill(healthOk = healthOk)
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (entry in sessionOptions) {
val active = entry.key == sessionKey
Surface(
onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else Color.White,
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Text(
text = friendlySessionName(entry.displayName ?: entry.key),
style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold),
color = if (active) Color.White else mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun ChatConnectionPill(healthOk: Boolean) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (healthOk) mobileSuccessSoft else mobileWarningSoft,
border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)),
) {
Text(
text = if (healthOk) "Connected" else "Offline",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = if (healthOk) mobileSuccess else mobileWarning,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = androidx.compose.ui.graphics.Color.White,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "CHAT ERROR",
style = mobileCaption2.copy(letterSpacing = 0.6.sp),
color = mobileDanger,
)
Text(text = errorText, style = mobileCallout, color = mobileText)
}
}
}
data class PendingImageAttachment(
val id: String,
val fileName: String,

View File

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

View File

@@ -0,0 +1,573 @@
package ai.openclaw.android.voice
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
enum class VoiceConversationRole {
User,
Assistant,
}
data class VoiceConversationEntry(
val id: String,
val role: VoiceConversationRole,
val text: String,
val isStreaming: Boolean = false,
)
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
/**
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
* round-trip so [pendingRunId] is set before any chat events can arrive.
*/
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
private val speakAssistantReply: suspend (String) -> Unit = {},
) {
companion object {
private const val tag = "MicCapture"
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private val _micEnabled = MutableStateFlow(false)
val micEnabled: StateFlow<Boolean> = _micEnabled
private val _micCooldown = MutableStateFlow(false)
val micCooldown: StateFlow<Boolean> = _micCooldown
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening
private val _statusText = MutableStateFlow("Mic off")
val statusText: StateFlow<String> = _statusText
private val _liveTranscript = MutableStateFlow<String?>(null)
val liveTranscript: StateFlow<String?> = _liveTranscript
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
val queuedMessages: StateFlow<List<String>> = _queuedMessages
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
private val _inputLevel = MutableStateFlow(0f)
val inputLevel: StateFlow<Float> = _inputLevel
private val _isSending = MutableStateFlow(false)
val isSending: StateFlow<Boolean> = _isSending
private val messageQueue = ArrayDeque<String>()
private val sessionSegments = mutableListOf<String>()
private var lastFinalSegment: String? = null
private var pendingRunId: String? = null
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var drainJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
fun setMicEnabled(enabled: Boolean) {
if (_micEnabled.value == enabled) return
_micEnabled.value = enabled
if (enabled) {
start()
sendQueuedIfIdle()
} else {
// Give the recognizer time to finish processing buffered audio.
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
drainJob?.cancel()
_micCooldown.value = true
drainJob = scope.launch {
delay(2000L)
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)
}
flushSessionToQueue()
drainJob = null
_micCooldown.value = false
sendQueuedIfIdle()
}
}
}
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
sendQueuedIfIdle()
return
}
if (messageQueue.isNotEmpty()) {
_statusText.value = queuedWaitingStatus()
}
}
fun handleGatewayEvent(event: String, payloadJson: String?) {
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val runId = pendingRunId ?: run { Log.d("MicCapture", "no pendingRunId — drop"); return }
val eventRunId = payload["runId"].asStringOrNull() ?: return
if (eventRunId != runId) { Log.d("MicCapture", "runId mismatch: event=$eventRunId pending=$runId"); return }
when (payload["state"].asStringOrNull()) {
"delta" -> {
val deltaText = parseAssistantText(payload)
if (!deltaText.isNullOrBlank()) {
upsertPendingAssistant(text = deltaText.trim(), isStreaming = true)
}
}
"final" -> {
val finalText = parseAssistantText(payload)?.trim().orEmpty()
if (finalText.isNotEmpty()) {
upsertPendingAssistant(text = finalText, isStreaming = false)
playAssistantReplyAsync(finalText)
} else if (pendingAssistantEntryId != null) {
updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
}
completePendingTurn()
}
"error" -> {
val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" }
upsertPendingAssistant(text = errorMessage, isStreaming = false)
completePendingTurn()
}
"aborted" -> {
upsertPendingAssistant(text = "Response aborted", isStreaming = false)
completePendingTurn()
}
}
}
private fun start() {
stopRequested = false
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
_micEnabled.value = false
return
}
if (!hasMicPermission()) {
_statusText.value = "Microphone permission required"
_micEnabled.value = false
return
}
mainHandler.post {
try {
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
}
startListeningSession()
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
}
}
}
private fun stop() {
stopRequested = true
restartJob?.cancel()
restartJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
private fun startListeningSession() {
val recognizerInstance = recognizer ?: return
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
speechPossibleSilenceMs,
)
}
_statusText.value =
when {
_isSending.value -> "Listening · sending queued voice"
messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued"
else -> "Listening"
}
_isListening.value = true
recognizerInstance.startListening(intent)
}
private fun scheduleRestart(delayMs: Long = 300L) {
if (stopRequested) return
if (!_micEnabled.value) return
restartJob?.cancel()
restartJob =
scope.launch {
delay(delayMs)
mainHandler.post {
if (stopRequested || !_micEnabled.value) return@post
try {
startListeningSession()
} catch (_: Throwable) {
// retry through onError
}
}
}
}
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()
_liveTranscript.value = null
lastFinalSegment = null
if (message.isEmpty()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
)
messageQueue.addLast(message)
publishQueue()
}
private fun publishQueue() {
_queuedMessages.value = messageQueue.toList()
}
private fun sendQueuedIfIdle() {
if (_isSending.value) return
if (messageQueue.isEmpty()) {
if (_micEnabled.value) {
_statusText.value = "Listening"
} else {
_statusText.value = "Mic off"
}
return
}
if (!gatewayConnected) {
_statusText.value = queuedWaitingStatus()
return
}
val next = messageQueue.first()
_isSending.value = true
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
_statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice"
scope.launch {
try {
val runId = sendToGateway(next) { earlyRunId ->
// Called with the idempotency key before chat.send fires so that
// pendingRunId is populated before any chat events can arrive.
pendingRunId = earlyRunId
}
// Update to the real runId if the gateway returned a different one.
if (runId != null && runId != pendingRunId) pendingRunId = runId
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
messageQueue.removeFirst()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
_isSending.value = false
pendingRunId = null
pendingAssistantEntryId = null
_statusText.value =
if (!gatewayConnected) {
queuedWaitingStatus()
} else {
"Send failed: ${err.message ?: err::class.simpleName}"
}
}
}
}
private fun armPendingRunTimeout(runId: String) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob =
scope.launch {
delay(pendingRunTimeoutMs)
if (pendingRunId != runId) return@launch
pendingRunId = null
pendingAssistantEntryId = null
_isSending.value = false
_statusText.value =
if (gatewayConnected) {
"Voice reply timed out; retrying queued turn"
} else {
queuedWaitingStatus()
}
sendQueuedIfIdle()
}
}
private fun completePendingTurn() {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
if (messageQueue.isNotEmpty()) {
messageQueue.removeFirst()
publishQueue()
}
pendingRunId = null
pendingAssistantEntryId = null
_isSending.value = false
sendQueuedIfIdle()
}
private fun queuedWaitingStatus(): String {
return "${messageQueue.size} queued · waiting for gateway"
}
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean = false,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
return id
}
private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
val current = _conversation.value
if (current.isEmpty()) return
val targetIndex =
when {
current[current.lastIndex].id == id -> current.lastIndex
else -> current.indexOfFirst { it.id == id }
}
if (targetIndex < 0) return
val entry = current[targetIndex]
val updatedText = text ?: entry.text
if (updatedText == entry.text && entry.isStreaming == isStreaming) return
val updated = current.toMutableList()
updated[targetIndex] = entry.copy(text = updatedText, isStreaming = isStreaming)
_conversation.value = updated
}
private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
val currentId = pendingAssistantEntryId
if (currentId == null) {
pendingAssistantEntryId =
appendConversation(
role = VoiceConversationRole.Assistant,
text = text,
isStreaming = isStreaming,
)
return
}
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
}
private fun playAssistantReplyAsync(text: String) {
val spoken = text.trim()
if (spoken.isEmpty()) return
scope.launch {
try {
speakAssistantReply(spoken)
} catch (err: Throwable) {
Log.w(tag, "assistant speech failed: ${err.message ?: err::class.simpleName}")
}
}
}
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
_micEnabled.value = false
_isListening.value = false
_inputLevel.value = 0f
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
private fun hasMicPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun parseAssistantText(payload: JsonObject): String? {
val message = payload["message"].asObjectOrNull() ?: return null
if (message["role"].asStringOrNull() != "assistant") return null
val content = message["content"] as? JsonArray ?: return null
val parts =
content.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
if (obj["type"].asStringOrNull() != "text") return@mapNotNull null
obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
}
if (parts.isEmpty()) return null
return parts.joinToString("\n")
}
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
_inputLevel.value = level
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
_inputLevel.value = 0f
scheduleRestart()
}
override fun onError(error: Int) {
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
val status =
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
else -> "Speech error ($error)"
}
_statusText.value = status
if (
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
) {
disableMic(status)
return
}
val restartDelayMs =
when (error) {
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> 1_200L
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
else -> 600L
}
scheduleRestart(delayMs = restartDelayMs)
}
override fun onResults(results: Bundle?) {
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)).
}
scheduleRestart()
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
_liveTranscript.value = text.trim()
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
}
}
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? =
this as? JsonObject
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -0,0 +1,35 @@
package ai.openclaw.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Test
class DeviceAuthPayloadTest {
@Test
fun buildV3_matchesCanonicalVector() {
val payload =
DeviceAuthPayload.buildV3(
deviceId = "dev-1",
clientId = "openclaw-macos",
clientMode = "ui",
role = "operator",
scopes = listOf("operator.admin", "operator.read"),
signedAtMs = 1_700_000_000_000,
token = "tok-123",
nonce = "nonce-abc",
platform = " IOS ",
deviceFamily = " iPhone ",
)
assertEquals(
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
payload,
)
}
@Test
fun normalizeMetadataField_asciiOnlyLowercase() {
assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS "))
assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC "))
assertEquals("", DeviceAuthPayload.normalizeMetadataField(null))
}
}

View File

@@ -0,0 +1,343 @@
package ai.openclaw.android.gateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
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.annotation.Config
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
private const val CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private val tokens = mutableMapOf<String, String>()
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
override fun saveToken(deviceId: String, role: String, token: String) {
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
}
}
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: Job,
)
private data class InvokeScenarioResult(
val request: GatewaySession.InvokeRequest,
val resultParams: JsonObject,
)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference<String?>(null)
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
assertEquals("invoke-1", result.request.id)
assertEquals("node-1", result.request.nodeId)
assertEquals("debug.ping", result.request.command)
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
true,
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
)
}
@Test
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
assertEquals("invoke-2", result.request.id)
assertEquals("node-2", result.request.nodeId)
assertEquals("debug.raw", result.request.command)
assertEquals("""{"raw":true}""", result.request.paramsJson)
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
}
@Test
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
"CAMERA_PERMISSION_REQUIRED",
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
)
assertEquals(
"grant Camera permission",
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
)
}
@Test
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val refreshRequestParams = CompletableDeferred<String?>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
}
"node.canvas.capability.refresh" -> {
if (!refreshRequestParams.isCompleted) {
refreshRequestParams.complete(frame["params"]?.toString())
}
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
)
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
assertEquals(true, refreshed)
assertEquals("{}", refreshParamsJson)
assertEquals(
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
harness.session.currentCanvasHostUrl(),
)
} finally {
shutdownHarness(harness, server)
}
}
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
private fun createNodeHarness(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(),
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = onInvoke,
)
return NodeHarness(session = session, sessionJob = sessionJob)
}
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|$port",
name = "test",
host = "127.0.0.1",
port = port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
}
private suspend fun awaitConnectedOrThrow(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
server: MockWebServer,
) {
val connectedWithinTimeout =
withTimeoutOrNull(TEST_TIMEOUT_MS) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
}
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
server.shutdown()
}
private suspend fun runInvokeScenario(
invokeEventFrame: String,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): InvokeScenarioResult {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(
json = json,
onHandshake = onHandshake,
) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send(invokeEventFrame)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
onInvoke(req)
}
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
return InvokeScenarioResult(request = request, resultParams = resultParams)
} finally {
shutdownHarness(harness, server)
}
}
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}
private fun startGatewayServer(
json: Json,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
): MockWebServer =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
onHandshake?.invoke(request)
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(CONNECT_CHALLENGE_FRAME)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
onRequestFrame(webSocket, id, method, frame)
}
},
)
}
}
start()
}
}

View File

@@ -0,0 +1,47 @@
package ai.openclaw.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewaySessionInvokeTimeoutTest {
@Test
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(0L))
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(5_000L))
}
@Test
fun resolveInvokeResultAckTimeoutMs_usesInvokeBudgetWithinBounds() {
assertEquals(30_000L, resolveInvokeResultAckTimeoutMs(30_000L))
assertEquals(90_000L, resolveInvokeResultAckTimeoutMs(90_000L))
}
@Test
fun resolveInvokeResultAckTimeoutMs_capsAtUpperBound() {
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
}
@Test
fun replaceCanvasCapabilityInScopedHostUrl_rewritesTerminalCapabilitySegment() {
assertEquals(
"http://127.0.0.1:18789/__openclaw__/cap/new-token",
replaceCanvasCapabilityInScopedHostUrl(
"http://127.0.0.1:18789/__openclaw__/cap/old-token",
"new-token",
),
)
}
@Test
fun replaceCanvasCapabilityInScopedHostUrl_rewritesWhenQueryAndFragmentPresent() {
assertEquals(
"http://127.0.0.1:18789/__openclaw__/cap/new-token?a=1#frag",
replaceCanvasCapabilityInScopedHostUrl(
"http://127.0.0.1:18789/__openclaw__/cap/old-token?a=1#frag",
"new-token",
),
)
}
}

View File

@@ -0,0 +1,33 @@
package ai.openclaw.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class InvokeErrorParserTest {
@Test
fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() {
val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code)
assertEquals("grant Camera permission", parsed.message)
assertTrue(parsed.hadExplicitCode)
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage)
}
@Test
fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() {
val parsed = parseInvokeErrorMessage("IllegalStateException: boom")
assertEquals("UNAVAILABLE", parsed.code)
assertEquals("IllegalStateException: boom", parsed.message)
assertFalse(parsed.hadExplicitCode)
}
@Test
fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() {
val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback")
assertEquals("UNAVAILABLE", parsed.code)
assertEquals("fallback", parsed.message)
assertFalse(parsed.hadExplicitCode)
}
}

View File

@@ -0,0 +1,110 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCalendarEvents_requiresPermission() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
val result = handler.handleCalendarEvents(null)
assertFalse(result.ok)
assertEquals("CALENDAR_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleCalendarAdd_rejectsEndBeforeStart() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = true, canWrite = true))
val result =
handler.handleCalendarAdd(
"""{"title":"Standup","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T09:00:00Z"}""",
)
assertFalse(result.ok)
assertEquals("CALENDAR_INVALID", result.error?.code)
}
@Test
fun handleCalendarEvents_returnsEvents() {
val event =
CalendarEventRecord(
identifier = "101",
title = "Sprint Planning",
startISO = "2026-02-28T10:00:00Z",
endISO = "2026-02-28T11:00:00Z",
isAllDay = false,
location = "Room 1",
calendarTitle = "Work",
)
val handler =
CalendarHandler.forTesting(
appContext(),
FakeCalendarDataSource(canRead = true, events = listOf(event)),
)
val result = handler.handleCalendarEvents("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val events = payload.getValue("events").jsonArray
assertEquals(1, events.size)
assertEquals("Sprint Planning", events.first().jsonObject.getValue("title").jsonPrimitive.content)
}
@Test
fun handleCalendarAdd_mapsNotFoundErrorCode() {
val source =
FakeCalendarDataSource(
canRead = true,
canWrite = true,
addError = IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar"),
)
val handler = CalendarHandler.forTesting(appContext(), source)
val result =
handler.handleCalendarAdd(
"""{"title":"Call","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T11:00:00Z"}""",
)
assertFalse(result.ok)
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
}
}
private class FakeCalendarDataSource(
private val canRead: Boolean,
private val canWrite: Boolean = false,
private val events: List<CalendarEventRecord> = emptyList(),
private val addResult: CalendarEventRecord =
CalendarEventRecord(
identifier = "0",
title = "Default",
startISO = "2026-01-01T00:00:00Z",
endISO = "2026-01-01T01:00:00Z",
isAllDay = false,
location = null,
calendarTitle = null,
),
private val addError: Throwable? = null,
) : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun hasWritePermission(context: Context): Boolean = canWrite
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = events
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
addError?.let { throw it }
return addResult
}
}

View File

@@ -0,0 +1,25 @@
package ai.openclaw.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CameraHandlerTest {
@Test
fun isCameraClipWithinPayloadLimit_allowsZeroAndLimit() {
assertTrue(isCameraClipWithinPayloadLimit(0L))
assertTrue(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES))
}
@Test
fun isCameraClipWithinPayloadLimit_rejectsNegativeAndTooLarge() {
assertFalse(isCameraClipWithinPayloadLimit(-1L))
assertFalse(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES + 1L))
}
@Test
fun cameraClipMaxRawBytes_matchesExpectedBudget() {
assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES)
}
}

View File

@@ -0,0 +1,121 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleContactsSearch_requiresReadPermission() {
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
val result = handler.handleContactsSearch(null)
assertFalse(result.ok)
assertEquals("CONTACTS_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleContactsAdd_rejectsEmptyContact() {
val handler =
ContactsHandler.forTesting(
appContext(),
FakeContactsDataSource(canRead = true, canWrite = true),
)
val result = handler.handleContactsAdd("""{"givenName":" ","emails":[]}""")
assertFalse(result.ok)
assertEquals("CONTACTS_INVALID", result.error?.code)
}
@Test
fun handleContactsSearch_returnsContacts() {
val contact =
ContactRecord(
identifier = "1",
displayName = "Ada Lovelace",
givenName = "Ada",
familyName = "Lovelace",
organizationName = "Analytical Engine",
phoneNumbers = listOf("+12025550123"),
emails = listOf("ada@example.com"),
)
val handler =
ContactsHandler.forTesting(
appContext(),
FakeContactsDataSource(canRead = true, searchResults = listOf(contact)),
)
val result = handler.handleContactsSearch("""{"query":"ada","limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val contacts = payload.getValue("contacts").jsonArray
assertEquals(1, contacts.size)
assertEquals("Ada Lovelace", contacts.first().jsonObject.getValue("displayName").jsonPrimitive.content)
}
@Test
fun handleContactsAdd_returnsAddedContact() {
val added =
ContactRecord(
identifier = "2",
displayName = "Grace Hopper",
givenName = "Grace",
familyName = "Hopper",
organizationName = "US Navy",
phoneNumbers = listOf(),
emails = listOf("grace@example.com"),
)
val source = FakeContactsDataSource(canRead = true, canWrite = true, addResult = added)
val handler = ContactsHandler.forTesting(appContext(), source)
val result =
handler.handleContactsAdd(
"""{"givenName":"Grace","familyName":"Hopper","emails":["grace@example.com"]}""",
)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val contact = payload.getValue("contact").jsonObject
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
assertEquals(1, source.addCalls)
}
}
private class FakeContactsDataSource(
private val canRead: Boolean,
private val canWrite: Boolean = false,
private val searchResults: List<ContactRecord> = emptyList(),
private val addResult: ContactRecord =
ContactRecord(
identifier = "0",
displayName = "Default",
givenName = "",
familyName = "",
organizationName = "",
phoneNumbers = emptyList(),
emails = emptyList(),
),
) : ContactsDataSource {
var addCalls: Int = 0
private set
override fun hasReadPermission(context: Context): Boolean = canRead
override fun hasWritePermission(context: Context): Boolean = canWrite
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> = searchResults
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
addCalls += 1
return addResult
}
}

View File

@@ -0,0 +1,149 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
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 DeviceHandlerTest {
@Test
fun handleDeviceInfo_returnsStablePayload() {
val handler = DeviceHandler(appContext())
val result = handler.handleDeviceInfo(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content)
assertTrue(payload.getValue("deviceName").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("modelIdentifier").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("systemVersion").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("appVersion").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("appBuild").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("locale").jsonPrimitive.content.isNotBlank())
}
@Test
fun handleDeviceStatus_returnsExpectedShape() {
val handler = DeviceHandler(appContext())
val result = handler.handleDeviceStatus(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val battery = payload.getValue("battery").jsonObject
val storage = payload.getValue("storage").jsonObject
val thermal = payload.getValue("thermal").jsonObject
val network = payload.getValue("network").jsonObject
val state = battery.getValue("state").jsonPrimitive.content
assertTrue(state in setOf("unknown", "unplugged", "charging", "full"))
battery["level"]?.jsonPrimitive?.double?.let { level ->
assertTrue(level in 0.0..1.0)
}
battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
val totalBytes = storage.getValue("totalBytes").jsonPrimitive.content.toLong()
val freeBytes = storage.getValue("freeBytes").jsonPrimitive.content.toLong()
val usedBytes = storage.getValue("usedBytes").jsonPrimitive.content.toLong()
assertTrue(totalBytes >= 0L)
assertTrue(freeBytes >= 0L)
assertTrue(usedBytes >= 0L)
assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes)
val thermalState = thermal.getValue("state").jsonPrimitive.content
assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical"))
val networkStatus = network.getValue("status").jsonPrimitive.content
assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection"))
val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content }
assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") })
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
}
@Test
fun handleDevicePermissions_returnsExpectedShape() {
val handler = DeviceHandler(appContext())
val result = handler.handleDevicePermissions(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val permissions = payload.getValue("permissions").jsonObject
val expected =
listOf(
"camera",
"microphone",
"location",
"backgroundLocation",
"sms",
"notificationListener",
"notifications",
"photos",
"contacts",
"calendar",
"motion",
"screenCapture",
)
for (key in expected) {
val state = permissions.getValue(key).jsonObject
val status = state.getValue("status").jsonPrimitive.content
assertTrue(status == "granted" || status == "denied")
state.getValue("promptable").jsonPrimitive.boolean
}
}
@Test
fun handleDeviceHealth_returnsExpectedShape() {
val handler = DeviceHandler(appContext())
val result = handler.handleDeviceHealth(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val memory = payload.getValue("memory").jsonObject
val battery = payload.getValue("battery").jsonObject
val power = payload.getValue("power").jsonObject
val system = payload.getValue("system").jsonObject
val pressure = memory.getValue("pressure").jsonPrimitive.content
assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown"))
val totalRamBytes = memory.getValue("totalRamBytes").jsonPrimitive.content.toLong()
val availableRamBytes = memory.getValue("availableRamBytes").jsonPrimitive.content.toLong()
val usedRamBytes = memory.getValue("usedRamBytes").jsonPrimitive.content.toLong()
assertTrue(totalRamBytes >= 0L)
assertTrue(availableRamBytes >= 0L)
assertTrue(usedRamBytes >= 0L)
memory.getValue("lowMemory").jsonPrimitive.boolean
val batteryState = battery.getValue("state").jsonPrimitive.content
assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full"))
val chargingType = battery.getValue("chargingType").jsonPrimitive.content
assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock"))
battery["temperatureC"]?.jsonPrimitive?.double
battery["currentMa"]?.jsonPrimitive?.double
power.getValue("dozeModeEnabled").jsonPrimitive.boolean
power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
system["securityPatchLevel"]?.jsonPrimitive?.content
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
private fun parsePayload(payloadJson: String?): JsonObject {
val jsonString = payloadJson ?: error("expected payload")
return Json.parseToJsonElement(jsonString).jsonObject
}
}

View File

@@ -0,0 +1,166 @@
package ai.openclaw.android.node
import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawCapability
import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawSystemCommand
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class InvokeCommandRegistryTest {
private val coreCapabilities =
setOf(
OpenClawCapability.Canvas.rawValue,
OpenClawCapability.Screen.rawValue,
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
OpenClawCapability.AppUpdate.rawValue,
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
)
private val optionalCapabilities =
setOf(
OpenClawCapability.Camera.rawValue,
OpenClawCapability.Location.rawValue,
OpenClawCapability.Sms.rawValue,
OpenClawCapability.VoiceWake.rawValue,
OpenClawCapability.Motion.rawValue,
)
private val coreCommands =
setOf(
OpenClawDeviceCommand.Status.rawValue,
OpenClawDeviceCommand.Info.rawValue,
OpenClawDeviceCommand.Permissions.rawValue,
OpenClawDeviceCommand.Health.rawValue,
OpenClawNotificationsCommand.List.rawValue,
OpenClawNotificationsCommand.Actions.rawValue,
OpenClawSystemCommand.Notify.rawValue,
OpenClawPhotosCommand.Latest.rawValue,
OpenClawContactsCommand.Search.rawValue,
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
"app.update",
)
private val optionalCommands =
setOf(
OpenClawCameraCommand.Snap.rawValue,
OpenClawCameraCommand.Clip.rawValue,
OpenClawCameraCommand.List.rawValue,
OpenClawLocationCommand.Get.rawValue,
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@Test
fun advertisedCapabilities_respectsFeatureAvailability() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
assertContainsAll(capabilities, coreCapabilities)
assertMissingAll(capabilities, optionalCapabilities)
}
@Test
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
),
)
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
}
@Test
fun advertisedCommands_respectsFeatureAvailability() {
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
assertContainsAll(commands, coreCommands)
assertMissingAll(commands, optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
),
)
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_onlyIncludesSupportedMotionCommands() {
val commands =
InvokeCommandRegistry.advertisedCommands(
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
debugBuild = false,
),
)
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
debugBuild: Boolean = false,
): NodeRuntimeFlags =
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,
debugBuild = debugBuild,
)
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
expected.forEach { value -> assertTrue(actual.contains(value)) }
}
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
}
}

View File

@@ -0,0 +1,130 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MotionHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleMotionActivity_requiresPermission() =
runTest {
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = false))
val result = handler.handleMotionActivity(null)
assertFalse(result.ok)
assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleMotionActivity_rejectsInvalidJson() =
runTest {
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = true))
val result = handler.handleMotionActivity("[]")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleMotionActivity_returnsActivityPayload() =
runTest {
val activity =
MotionActivityRecord(
startISO = "2026-02-28T10:00:00Z",
endISO = "2026-02-28T10:00:02Z",
confidence = "high",
isWalking = true,
isRunning = false,
isCycling = false,
isAutomotive = false,
isStationary = false,
isUnknown = false,
)
val handler =
MotionHandler.forTesting(
appContext(),
FakeMotionDataSource(hasPermission = true, activityRecord = activity),
)
val result = handler.handleMotionActivity(null)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val activities = payload.getValue("activities").jsonArray
assertEquals(1, activities.size)
assertEquals("high", activities.first().jsonObject.getValue("confidence").jsonPrimitive.content)
}
@Test
fun handleMotionPedometer_mapsRangeUnsupportedError() =
runTest {
val handler =
MotionHandler.forTesting(
appContext(),
FakeMotionDataSource(
hasPermission = true,
pedometerError = IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: not supported"),
),
)
val result = handler.handleMotionPedometer("""{"startISO":"2026-02-01T00:00:00Z"}""")
assertFalse(result.ok)
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
}
}
private class FakeMotionDataSource(
private val hasPermission: Boolean,
private val activityAvailable: Boolean = true,
private val pedometerAvailable: Boolean = true,
private val activityRecord: MotionActivityRecord =
MotionActivityRecord(
startISO = "2026-02-28T00:00:00Z",
endISO = "2026-02-28T00:00:02Z",
confidence = "medium",
isWalking = false,
isRunning = false,
isCycling = false,
isAutomotive = false,
isStationary = true,
isUnknown = false,
),
private val pedometerRecord: PedometerRecord =
PedometerRecord(
startISO = "2026-02-28T00:00:00Z",
endISO = "2026-02-28T01:00:00Z",
steps = 1234,
distanceMeters = null,
floorsAscended = null,
floorsDescended = null,
),
private val activityError: Throwable? = null,
private val pedometerError: Throwable? = null,
) : MotionDataSource {
override fun isActivityAvailable(context: Context): Boolean = activityAvailable
override fun isPedometerAvailable(context: Context): Boolean = pedometerAvailable
override fun hasPermission(context: Context): Boolean = hasPermission
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
activityError?.let { throw it }
return activityRecord
}
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
pedometerError?.let { throw it }
return pedometerRecord
}
}

View File

@@ -0,0 +1,11 @@
package ai.openclaw.android.node
import android.content.Context
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
abstract class NodeHandlerRobolectricTest {
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
}

View File

@@ -0,0 +1,258 @@
package ai.openclaw.android.node
import android.content.Context
import ai.openclaw.android.gateway.GatewaySession
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
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 NotificationsHandlerTest {
@Test
fun notificationsListReturnsStatusPayloadWhenDisabled() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = false,
connected = false,
notifications = emptyList(),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsList(null)
assertTrue(result.ok)
assertNull(result.error)
val payload = parsePayload(result)
assertFalse(payload.getValue("enabled").jsonPrimitive.boolean)
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
assertEquals(0, payload.getValue("count").jsonPrimitive.int)
assertEquals(0, payload.getValue("notifications").jsonArray.size)
assertEquals(0, provider.rebindRequests)
}
@Test
fun notificationsListRequestsRebindWhenEnabledButDisconnected() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = false,
notifications = listOf(sampleEntry("n1")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsList(null)
assertTrue(result.ok)
assertNull(result.error)
val payload = parsePayload(result)
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
assertEquals(1, payload.getValue("notifications").jsonArray.size)
assertEquals(1, provider.rebindRequests)
}
@Test
fun notificationsListDoesNotRequestRebindWhenConnected() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n2")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsList(null)
assertTrue(result.ok)
assertNull(result.error)
val payload = parsePayload(result)
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
assertTrue(payload.getValue("connected").jsonPrimitive.boolean)
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
assertEquals(0, provider.rebindRequests)
}
@Test
fun notificationsActions_executesDismissAction() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n2")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""")
assertTrue(result.ok)
assertNull(result.error)
val payload = parsePayload(result)
assertTrue(payload.getValue("ok").jsonPrimitive.boolean)
assertEquals("n2", payload.getValue("key").jsonPrimitive.content)
assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content)
assertEquals("n2", provider.lastAction?.key)
assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind)
}
@Test
fun notificationsActions_requiresReplyTextForReplyAction() =
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":"reply"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_propagatesProviderError() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n4")),
),
).also {
it.actionResult =
NotificationActionResult(
ok = false,
code = "NOTIFICATION_NOT_FOUND",
message = "NOTIFICATION_NOT_FOUND: notification key not found",
)
}
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
assertFalse(result.ok)
assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code)
assertEquals(1, provider.actionRequests)
}
@Test
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = false,
notifications = listOf(sampleEntry("n5")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n5","action":"open"}""")
assertTrue(result.ok)
assertEquals(1, provider.rebindRequests)
assertEquals(1, provider.actionRequests)
}
@Test
fun sanitizeNotificationTextReturnsNullForBlankInput() {
assertNull(sanitizeNotificationText(null))
assertNull(sanitizeNotificationText(" "))
}
@Test
fun sanitizeNotificationTextTrimsAndTruncates() {
val value = " ${"x".repeat(600)} "
val sanitized = sanitizeNotificationText(value)
assertEquals(512, sanitized?.length)
assertTrue((sanitized ?: "").all { it == 'x' })
}
@Test
fun notificationsActionClearablePolicy_onlyRequiresClearableForDismiss() {
assertTrue(actionRequiresClearableNotification(NotificationActionKind.Dismiss))
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Open))
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Reply))
}
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
val payloadJson = result.payloadJson ?: error("expected payload")
return Json.parseToJsonElement(payloadJson).jsonObject
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
private fun sampleEntry(key: String): DeviceNotificationEntry =
DeviceNotificationEntry(
key = key,
packageName = "com.example.app",
title = "Title",
text = "Text",
subText = null,
category = null,
channelId = null,
postTimeMs = 123L,
isOngoing = false,
isClearable = true,
)
}
private class FakeNotificationsStateProvider(
private val snapshot: DeviceNotificationSnapshot,
) : NotificationsStateProvider {
var rebindRequests: Int = 0
private set
var actionRequests: Int = 0
private set
var actionResult: NotificationActionResult = NotificationActionResult(ok = true)
var lastAction: NotificationActionRequest? = null
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
override fun requestServiceRebind(context: Context) {
rebindRequests += 1
}
override fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult {
actionRequests += 1
lastAction = request
return actionResult
}
}

View File

@@ -0,0 +1,71 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handlePhotosLatest_requiresPermission() {
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
val result = handler.handlePhotosLatest(null)
assertFalse(result.ok)
assertEquals("PHOTOS_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handlePhotosLatest_rejectsInvalidJson() {
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = true))
val result = handler.handlePhotosLatest("[]")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handlePhotosLatest_returnsPayload() {
val source =
FakePhotosDataSource(
hasPermission = true,
latest = listOf(
EncodedPhotoPayload(
format = "jpeg",
base64 = "abc123",
width = 640,
height = 480,
createdAt = "2026-02-28T00:00:00Z",
),
),
)
val handler = PhotosHandler.forTesting(appContext(), source)
val result = handler.handlePhotosLatest("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val photos = payload.getValue("photos").jsonArray
assertEquals(1, photos.size)
val first = photos.first().jsonObject
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
assertEquals(640, first.getValue("width").jsonPrimitive.int)
}
}
private class FakePhotosDataSource(
private val hasPermission: Boolean,
private val latest: List<EncodedPhotoPayload> = emptyList(),
) : PhotosDataSource {
override fun hasPermission(context: Context): Boolean = hasPermission
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> = latest
}

View File

@@ -0,0 +1,83 @@
package ai.openclaw.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SystemHandlerTest {
@Test
fun handleSystemNotify_rejectsUnauthorized() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false))
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""")
assertFalse(result.ok)
assertEquals("NOT_AUTHORIZED", result.error?.code)
}
@Test
fun handleSystemNotify_rejectsEmptyNotification() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
val result = handler.handleSystemNotify("""{"title":" ","body":" "}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleSystemNotify_postsNotification() {
val poster = FakePoster(authorized = true)
val handler = SystemHandler.forTesting(poster = poster)
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""")
assertTrue(result.ok)
assertEquals(1, poster.posts)
}
@Test
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
assertFalse(result.ok)
assertEquals("NOT_AUTHORIZED", result.error?.code)
}
@Test
fun handleSystemNotify_returnsUnavailableWhenPostFailsUnexpectedly() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = IllegalStateException("boom")))
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
assertFalse(result.ok)
assertEquals("UNAVAILABLE", result.error?.code)
}
}
private class FakePoster(
private val authorized: Boolean,
) : SystemNotificationPoster {
var posts: Int = 0
private set
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest) {
posts += 1
}
}
private class ThrowingPoster(
private val authorized: Boolean,
private val error: Throwable,
) : SystemNotificationPoster {
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest) {
throw error
}
}

View File

@@ -26,10 +26,69 @@ class OpenClawProtocolConstantsTest {
assertEquals("camera", OpenClawCapability.Camera.rawValue)
assertEquals("screen", OpenClawCapability.Screen.rawValue)
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
assertEquals("system", OpenClawCapability.System.rawValue)
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
assertEquals("photos", OpenClawCapability.Photos.rawValue)
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue)
}
@Test
fun cameraCommandsUseStableStrings() {
assertEquals("camera.list", OpenClawCameraCommand.List.rawValue)
assertEquals("camera.snap", OpenClawCameraCommand.Snap.rawValue)
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
}
@Test
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
}
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
assertEquals("notifications.actions", OpenClawNotificationsCommand.Actions.rawValue)
}
@Test
fun deviceCommandsUseStableStrings() {
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
}
@Test
fun systemCommandsUseStableStrings() {
assertEquals("system.notify", OpenClawSystemCommand.Notify.rawValue)
}
@Test
fun photosCommandsUseStableStrings() {
assertEquals("photos.latest", OpenClawPhotosCommand.Latest.rawValue)
}
@Test
fun contactsCommandsUseStableStrings() {
assertEquals("contacts.search", OpenClawContactsCommand.Search.rawValue)
assertEquals("contacts.add", OpenClawContactsCommand.Add.rawValue)
}
@Test
fun calendarCommandsUseStableStrings() {
assertEquals("calendar.events", OpenClawCalendarCommand.Events.rawValue)
assertEquals("calendar.add", OpenClawCalendarCommand.Add.rawValue)
}
@Test
fun motionCommandsUseStableStrings() {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
}

View File

@@ -0,0 +1,59 @@
package ai.openclaw.android.ui
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
val qrJson =
"""
{
"setupCode": "$setupCode",
"gatewayUrl": "wss://gateway.example:18789",
"auth": "password",
"urlSource": "gateway.remote.url"
}
""".trimIndent()
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}
}

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