Compare commits

..

292 Commits

Author SHA1 Message Date
Dallin Romney
48bdbad66a test: pin folded QA coverage ids 2026-06-19 13:29:26 -07:00
Dallin Romney
781e2ada63 test: avoid overclaiming gateway tool API coverage 2026-06-19 12:16:56 -07:00
Dallin Romney
fa78189458 test: preserve chat tools profile build guard 2026-06-19 12:16:56 -07:00
Dallin Romney
dafcdb901f test: update mirrored QA routing expectation 2026-06-19 12:16:56 -07:00
Dallin Romney
0b6eb3230c test: keep native QA evidence out of parity tiers 2026-06-19 12:16:56 -07:00
Dallin Romney
c1fe62ee83 test: align folded QA coverage ids 2026-06-19 12:16:56 -07:00
Dallin Romney
aa60716363 test: trim folded QA Lab script cruft 2026-06-19 12:16:56 -07:00
Dallin Romney
62dea06219 test: relax QA native scenario catalog inventory 2026-06-19 12:16:56 -07:00
Dallin Romney
e4270e7709 test: remove folded HTTP API script tests 2026-06-19 12:16:56 -07:00
Dallin Romney
f237f1da6d test: fold HTTP API script proof into QA Lab 2026-06-19 12:16:06 -07:00
Vincent Koc
6cfb025143 fix(e2e): reject unsafe chat tools body lengths
Reject unsafe numeric Content-Length values in the OpenAI chat tools E2E client before waiting on the response stream.

Also hardens Docker E2E heartbeat timing coverage after the exact-head release gate exposed a brittle zero-padded heartbeat assertion.

Verification: direct mock gateway repro, docker heartbeat shell proof, autoreview clean, and exact-head CI release gate https://github.com/openclaw/openclaw/actions/runs/27843455246.
2026-06-20 03:09:51 +08:00
Vincent Koc
061a3705db test(plugin-sdk): isolate runtime facade tests 2026-06-19 20:55:49 +02:00
Vincent Koc
9e5ac0cea4 refactor(extensions): drop stale internal declarations 2026-06-20 02:52:05 +08:00
Vincent Koc
aff6e221a7 fix(lmstudio): bound model load error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
5df5aa1640 fix(openai): bound batch error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
59a93a817f fix(openai): bound device code auth bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
23b8f5d037 refactor(discord): remove unused monitor hooks 2026-06-20 02:37:17 +08:00
Vincent Koc
17e2fbfa86 fix(test): harden script probe bounds (#95060)
Merged via squash.

Prepared head SHA: 3a51c3c2d7
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-20 02:31:40 +08:00
Vincent Koc
cbff4fa5bc refactor(extensions): drop unused internal type aliases 2026-06-20 02:22:31 +08:00
Vincent Koc
330545f3e9 refactor(voice-call): drop unused stream helpers 2026-06-20 02:07:08 +08:00
Vincent Koc
2b0a72bb48 fix(release): lazy-load sigstore verification 2026-06-19 20:02:21 +02:00
Lu Wang
583829a342 fix(ssh): scope tunnel port preflight to loopback (#94603) (#94607)
Merged via squash.

Prepared head SHA: 6798b718de
Co-authored-by: wangwllu <7668944+wangwllu@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 18:59:58 +01:00
Vincent Koc
7b94ae9944 refactor(discord): drop unused internal wrapper methods 2026-06-20 01:52:02 +08:00
Vincent Koc
1609365b3e test(state): canonicalize sqlite volume assertions 2026-06-19 19:45:40 +02:00
Josh Lehman
d216f7c876 refactor: use canonical transcript reader identity (#89581)
* refactor: use canonical transcript reader identity

* refactor: keep transcript reader dependency storage-neutral
2026-06-19 10:40:18 -07:00
Vincent Koc
d41a3d28a0 refactor(oc-path): drop unused repack helper 2026-06-20 01:32:16 +08:00
Vincent Koc
8aa58c5fb0 fix(minimax): bound oauth token bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
e7e85f5436 fix(minimax): bound oauth error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
458904037f fix(parallel): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
1e53ee4fd5 fix(exa): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
6037d1a85c fix(ollama): bound stream error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
2c8d19d73e fix(ollama): bound embedding error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
70a48a680d fix(sdk): refresh plugin api baseline hash 2026-06-19 19:18:38 +02:00
Vincent Koc
0c210e5e52 fix(discord): deliver reasoning replies (#95029) 2026-06-20 01:18:14 +08:00
Vincent Koc
38807ffba4 test(plugins): isolate public surface runtime env 2026-06-19 19:08:32 +02:00
Vincent Koc
fb06df6cad refactor(voice-call): drop unused config type aliases 2026-06-20 01:07:03 +08:00
Vincent Koc
50614c51a8 test(ui): isolate chat browser layout fixtures 2026-06-19 18:54:19 +02:00
Vincent Koc
1f244f60ed test(secrets): load external plugin secret coverage 2026-06-19 18:35:29 +02:00
Vincent Koc
10b8b32380 refactor(codex): drop unused app-server helpers 2026-06-20 00:34:03 +08:00
Shakker
3b65f1d279 test: isolate sandbox registry state env 2026-06-19 17:32:09 +01:00
Yzx
1c711048f9 fix(agents): route plugin approvals through transport channel (#90918) 2026-06-19 12:31:06 -04:00
Vincent Koc
f69f81af9e fix(cli): use gateway skills status when available 2026-06-19 18:28:39 +02:00
Shakker
cdf4268540 fix: scope workspace default env 2026-06-19 17:24:03 +01:00
Vincent Koc
b4651f3781 refactor(codex): drop unused memory tool wrapper 2026-06-20 00:16:50 +08:00
Shakker
107c49e936 test: scope models config auth env 2026-06-19 17:10:24 +01:00
Shakker
ffd8c6e5d9 fix: scope model auth env helpers 2026-06-19 17:07:53 +01:00
Vincent Koc
9fced92710 test(wizard): align secret ref provider alias 2026-06-19 18:07:06 +02:00
Vincent Koc
3bcdf20a44 test(secrets): align secret ref fixtures 2026-06-19 18:07:06 +02:00
Shakker
80010a864b test: route subagent registry state env 2026-06-19 16:57:15 +01:00
Shakker
a536a0ddbc fix: isolate cli attempt home env 2026-06-19 16:54:05 +01:00
Vincent Koc
925d98d8e4 refactor(codex): drop unused prompt overlay wrapper 2026-06-19 23:51:44 +08:00
Vincent Koc
a42a1af942 fix(openrouter): bound oauth error bodies 2026-06-19 17:43:29 +02:00
Vincent Koc
b470b1e21a fix(mistral): sanitize realtime API key input 2026-06-19 17:37:09 +02:00
Vincent Koc
6fc0303ec0 fix(chutes): bound oauth token error bodies 2026-06-19 17:29:36 +02:00
Vincent Koc
6ef4684b89 fix(scripts): skip generated dist in legacy store guard 2026-06-19 17:22:14 +02:00
Vincent Koc
2005812dff fix(secrets): validate refs consistently at runtime 2026-06-19 17:22:14 +02:00
Vincent Koc
bf872b30cd test: remove unused mock alias exports 2026-06-19 23:19:46 +08:00
Vincent Koc
37962aac95 test(qqbot): keep stt temp helper on sdk surface 2026-06-19 17:03:16 +02:00
Vincent Koc
a876f8d073 fix(qqbot): bound chunked upload error bodies 2026-06-19 17:03:16 +02:00
Vincent Koc
0a3e0d081d test: remove no-op mock registrars 2026-06-19 22:55:38 +08:00
Vincent Koc
2c3b582c04 fix(scripts): avoid pnpm in parallels smoke wrappers 2026-06-19 16:47:03 +02:00
Vincent Koc
e0d58d994d fix(qqbot): bound stt error bodies 2026-06-19 16:44:51 +02:00
Vincent Koc
dc16aedd2e test(launcher): isolate bundled plugin env in fixtures 2026-06-19 16:42:44 +02:00
Vincent Koc
b16fd6bee7 test(qqbot): fix channel api bounded body assertion 2026-06-19 16:35:55 +02:00
Vincent Koc
51ebe87a09 fix(qqbot): guard channel api fetches 2026-06-19 16:30:53 +02:00
Vincent Koc
78b5618071 test(ui): isolate browser ownership in e2e fixtures 2026-06-19 16:24:38 +02:00
Vincent Koc
ed8ab712dc fix(qqbot): guard api client fetches 2026-06-19 16:14:19 +02:00
Vincent Koc
8594af21e9 fix(qqbot): bound token response bodies 2026-06-19 16:14:19 +02:00
Vincent Koc
2ddebf3897 refactor(config): drop duplicate account schema aliases 2026-06-19 22:12:44 +08:00
Vincent Koc
b9dadb9f66 test(ui): isolate sessions browser layout fixtures 2026-06-19 16:08:54 +02:00
Vincent Koc
f062171c54 test(ui): isolate mobile form control browser fixtures 2026-06-19 16:03:16 +02:00
pick-cat
b677ea6726 fix(agent): resolve compaction model alias to canonical model ref (#90885)
Merged via squash.

Prepared head SHA: 72d28dc385
Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 15:03:06 +01:00
Vincent Koc
e74a7d2f14 fix(sdk): refresh api baseline hash 2026-06-19 15:57:57 +02:00
Vincent Koc
917a0f3052 refactor(extensions): drop private alias exports 2026-06-19 21:43:24 +08:00
Vincent Koc
b3dfa0f1b1 refactor(shared): drop unused internal format/import aliases 2026-06-19 21:36:52 +08:00
Vincent Koc
772158c716 fix(qqbot): bound api error bodies 2026-06-19 15:30:29 +02:00
Vincent Koc
940d33cf89 fix(scripts): clean package download temp files after stream abort 2026-06-19 15:22:55 +02:00
Vincent Koc
698efb23a6 fix(discord): bound api error bodies 2026-06-19 15:21:07 +02:00
Vincent Koc
d29c3a5d6f refactor(cron): drop duplicate active-job reset alias 2026-06-19 21:15:22 +08:00
Peter Steinberger
341ae21d03 feat(slack): handle global and message shortcuts (#94881)
Merged via squash.

Prepared head SHA: 32dea12d7a
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:12:30 +01:00
Andrew Stroup
378c4134f1 fix(slack): default member-info userId to inbound sender (#89236)
Merged via squash.

Prepared head SHA: c7a39e54f7
Co-authored-by: stroupaloop <2424551+stroupaloop@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:03:29 +01:00
Vincent Koc
cd2d837a1f fix(slack): preserve buffered thread stream replies (#78536)
Merged via squash.

Prepared head SHA: 0d8d75918d
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:02:50 +01:00
Vincent Koc
29e44f5eba refactor(tasks): drop duplicate maintenance stop alias 2026-06-19 20:56:56 +08:00
Vincent Koc
ce7f899165 fix(discord): bound voice upload error bodies 2026-06-19 14:50:29 +02:00
Vincent Koc
4c3b15bae6 fix(discord): bound webhook error bodies 2026-06-19 14:43:17 +02:00
Kendrick Ha
4723602e7e feat(channels): add Zalo ClawBot external channel entry and documenta… (#89586)
Merged via squash.

Prepared head SHA: 5ef4fe999a
Co-authored-by: ken-kuro <47441476+ken-kuro@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:38 +01:00
Peter Lee
430682e97a fix(xai): reject unsupported multi-agent model refs before runtime fallback (#93969)
Merged via squash.

Prepared head SHA: b58d798381
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:00 +01:00
Peter Lee
5c8761976c fix(whatsapp): restart listener on selfChatMode config change (#93873)
Merged via squash.

Prepared head SHA: d85f604f01
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:41:26 +01:00
Vincent Koc
7fafad8c49 refactor(plugins): drop duplicate memory reset alias 2026-06-19 20:38:03 +08:00
NIO
47545e04c4 fix(channels): stop duplicating inbound previews in system events (#94589)
Merged via squash.

Prepared head SHA: 981003591c
Co-authored-by: hugenshen <16300669+hugenshen@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:37:28 +01:00
Vincent Koc
aea208f0ac fix(discord): bound pluralkit error bodies 2026-06-19 14:29:11 +02:00
Vincent Koc
4d37f42df7 fix(github-copilot): bound embedding error bodies 2026-06-19 14:21:28 +02:00
Vincent Koc
56c5630107 refactor(agents): drop duplicate gateway allowlist test export 2026-06-19 20:19:11 +08:00
joshavant
99e69e16b7 remove ios identity migration 2026-06-19 14:16:48 +02:00
joshavant
f13dc76ba1 fix ios share extension device identity 2026-06-19 14:16:48 +02:00
Vincent Koc
0de3d47195 fix(google-meet): bound google api error bodies 2026-06-19 14:06:36 +02:00
Vincent Koc
f7c3775140 fix(test): prefer local bundled plugins in linked Vitest worktrees 2026-06-19 14:00:32 +02:00
Peter Steinberger
e2b52f29e4 test(plugins): separate activation-scoped web search ids
Exclude startup-lazy Codex and QA Lab entries from the loader-scoped baseline before asserting them as explicit activation-scoped contracts.
2026-06-19 07:58:44 -04:00
Vincent Koc
482d6d59ac refactor(plugin-state): drop duplicate close alias 2026-06-19 19:57:26 +08:00
Vincent Koc
ff35b29a06 fix(mattermost): stream guarded api responses 2026-06-19 13:51:37 +02:00
Peter Steinberger
5a00720de0 fix(ci): repair signing lint and test types
Use the canonical undefined comparison and preserve the gateway predicate mock signature so full release-gate lint and test-type checks pass.
2026-06-19 07:42:51 -04:00
Vincent Koc
817dd593bb test(commands): type gateway transport mock input 2026-06-19 13:34:04 +02:00
Vincent Koc
c218255815 test(plugins): pin activation-scoped web search contracts 2026-06-19 13:34:04 +02:00
Vincent Koc
3bc936b675 test(sdk): keep package e2e pnpm noninteractive 2026-06-19 13:34:04 +02:00
Vincent Koc
4799fe7df6 fix(msteams): stream graph success responses 2026-06-19 13:25:18 +02:00
Vincent Koc
f29af26326 fix(sms): bound twilio api response bodies 2026-06-19 13:24:16 +02:00
Vincent Koc
b0c1010fbf refactor(cron): drop duplicate isolated-agent test aliases 2026-06-19 19:19:08 +08:00
Vincent Koc
f14a2cb9c5 fix(clickclack): bound api error response bodies 2026-06-19 13:16:08 +02:00
wangmiao0668000666
27f702d68f fix(gateway): authorize plugin methods from attached registry (#94343)
Authorize plugin gateway methods against the exact registry attached to dispatch, preserving fallback behavior for dynamic methods and deleting one-off repro scripts.

Fixes #92044.

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
2026-06-19 11:56:24 +01:00
Super Zheng
0781dae620 fix(plugins): keep tool discovery request-local (#93276)
Keep plugin tool discovery request-local, preserve active provider/channel registries, and carry the prepared registry through MCP and catalog resolution.

Co-authored-by: 郑苏波 (Super Zheng) <superzheng@tencent.com>
2026-06-19 11:56:20 +01:00
Peter Lee
6256ad86c9 fix(gateway): classify probe reachability by validated transport (#93948)
Distinguish validated gateway reachability from pre-open and TLS-validation failures, and sanitize close diagnostics before terminal output.

Fixes #79099.

Co-authored-by: xialonglee <li.xialong@xydigit.com>
2026-06-19 11:56:16 +01:00
joshavant
f7f415f26b fix(ios): wire share extension app group signing 2026-06-19 12:53:45 +02:00
ZengWen-DT
2983edd5a2 docs(browser): clarify networkidle session support (#94020)
Clarify that `networkidle` is supported for managed and raw-CDP browser sessions but rejected for existing-session mode.

Fixes #80587.

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
2026-06-19 11:53:07 +01:00
Alix-007
4da36da605 feat(status): show session duration in footer (#88988)
Show elapsed session duration in the status footer using the canonical session lifecycle timestamps and compact formatter.

Fixes #68226.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-19 11:53:04 +01:00
Vincent Koc
92d1f04de3 refactor(agents): drop duplicate internal aliases 2026-06-19 18:33:56 +08:00
Vincent Koc
611ad1a097 fix(voice-call): bound provider api response bodies 2026-06-19 12:33:39 +02:00
Vincent Koc
6ef4970988 refactor(agents): drop unused harness registry wrappers 2026-06-19 18:22:26 +08:00
Peter Steinberger
8d9eba3f4f fix(ios): complete single-target watch migration
Use the watchOS application API for text input, remove simulator-only Debug architecture restrictions, and document the standard Watch bundle location. Refs #92477.

Co-authored-by: Sash Zats <sash@zats.io>
2026-06-19 06:18:43 -04:00
Vincent Koc
40dc8fd147 fix(plugins): cancel marketplace archive error bodies 2026-06-19 12:17:45 +02:00
Vincent Koc
2257a21b7e refactor(tests): drop duplicate helper aliases 2026-06-19 18:12:54 +08:00
David
d4833e27c7 fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket (#91685)
Summary:
- The PR changes isolated cron delivery resolution to reject keyless implicit delivery inherited from the shar ...  targets into delivery context resolution, and cleans up direct cron sessions on unresolved delivery exits.
- PR surface: Source +57, Tests +496. Total +553 across 8 files.
- Reproducibility: yes. from source inspection: current resolver can inherit the shared agent-main last target ... ls or sends based on that resolved target; I did not run live Matrix reproduction in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cron): clean up deleteAfterRun session when keyless cron delivery…
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'upstream/main' into fix/91613-isolated-…
- PR branch already contained follow-up commit before automerge: Merge upstream main into fix/91613-isolated-cron-delivery-identity
- PR branch already contained follow-up commit before automerge: chore: retrigger PR CI after upstream base fix

Validation:
- ClawSweeper review passed for head f129375dd7.
- Required merge gates passed before the squash merge.

Prepared head SHA: f129375dd7
Review: https://github.com/openclaw/openclaw/pull/91685#issuecomment-4659309145

Co-authored-by: nxmxbbd <32288+nxmxbbd@users.noreply.github.com>
2026-06-19 10:05:07 +00:00
clawsweeper[bot]
d1bb2d5a12 fix(telegram): normalize all HTML tables before entity-escaping in rich messages (#94856)
Summary:
- The PR changes Telegram legacy HTML rendering so raw HTML table tags are converted to `<pre><code>` pipe-tab ... ks before unsupported-tag escaping, while preserving pre/code literals and rich-message table sanitization.
- PR surface: Source +38, Tests +31. Total +69 across 2 files.
- Reproducibility: yes. Source inspection shows current main's legacy HTML renderer sends raw tables directly  ... the linked issue describes that same escaped output; I did not run tests because this review was read-only.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 5944f8e4d2.
- Required merge gates passed before the squash merge.

Prepared head SHA: 5944f8e4d2
Review: https://github.com/openclaw/openclaw/pull/94856#issuecomment-4749452707

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: zhangqueping <3436352+zhangqueping@users.noreply.github.com>
2026-06-19 10:04:22 +00:00
Vincent Koc
eb7da0a2e5 fix(plugins): cancel self-hosted probe error bodies 2026-06-19 12:03:31 +02:00
Vincent Koc
797865c9dc fix(cli): cancel camera URL error bodies 2026-06-19 11:57:43 +02:00
Vincent Koc
7fcbfa6971 refactor(plugins): drop unused web-channel send wrapper 2026-06-19 17:52:10 +08:00
Vincent Koc
3091c13713 refactor(acpx): drop unused codex trust wrapper 2026-06-19 17:49:50 +08:00
Vincent Koc
c159063c70 fix(plugins): bound embedding error bodies 2026-06-19 11:43:18 +02:00
Vincent Koc
dae37a4579 refactor(plugins): drop unused web-channel facade wrappers 2026-06-19 17:41:24 +08:00
clawsweeper[bot]
2e0dfda462 test(perf): compare saved CLI startup benchmarks (#94812)
Summary:
- Adds saved CLI startup benchmark report comparison flags to `scripts/bench-cli-startup.ts`, plus JSON output coverage and changed-target routing expectations for the new test-helper importer.
- PR surface: Tests +77, Other +109. Total +186 across 4 files.
- Reproducibility: not applicable. as a feature/tooling PR. The prior PR defects were source-proven in review comments and the current head addresses them; I did not run local tests because this review was read-only.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: test(perf): compare saved CLI startup benchmarks

Validation:
- ClawSweeper review passed for head 1afa110f1b.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1afa110f1b
Review: https://github.com/openclaw/openclaw/pull/94812#issuecomment-4748785428

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com>
2026-06-19 09:37:47 +00:00
Vincent Koc
5b3d652c05 fix(sdk): cancel Copilot token error bodies 2026-06-19 11:34:54 +02:00
Sash Zats
b39a932112 fix: migrate watch app to single-target app (Xcode 27+ compat) (#92477)
* fix: migrate watch app to single-target app

* fix: build watch screenshots generically

* docs(ios): clarify watch embed invariant

* docs(ios): clarify watch embed invariant

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-19 11:34:34 +02:00
scotthuang
0c76a98f10 fix(outbound): keep direct-only targets out of group sessions (#94683)
Merged via squash.

Prepared head SHA: d2cb01b5ba
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 11:31:25 +02:00
Ayaan Zaidi
a8b5f5d551 fix(telegram): send progress previews as html text 2026-06-19 14:56:35 +05:30
Vincent Koc
bbe9669926 fix(agents): cancel tool download error bodies 2026-06-19 11:19:57 +02:00
Vincent Koc
7580c80f37 refactor(channels): drop unused test helpers 2026-06-19 17:17:14 +08:00
Vincent Koc
7f38b1a910 fix(sdk): cancel live catalog error bodies 2026-06-19 11:11:59 +02:00
Vincent Koc
8aaf937bc0 fix(auth): cancel WHAM probe error bodies 2026-06-19 11:02:59 +02:00
Vincent Koc
6467c1962a fix(chutes): cancel userinfo error bodies 2026-06-19 10:57:41 +02:00
Vincent Koc
0c565f3b0e fix(usage): cancel provider error bodies 2026-06-19 10:51:58 +02:00
Vincent Koc
7211d77553 refactor(channels): drop unused approval aliases 2026-06-19 16:41:19 +08:00
Vincent Koc
dba291ed35 fix(agents): cancel OpenRouter catalog error bodies 2026-06-19 10:38:00 +02:00
Vincent Koc
32c02e843a refactor(browser): drop unused cdp helpers 2026-06-19 16:36:49 +08:00
Vincent Koc
5e329f4065 fix(channels): preserve command progress detail (#94868)
Merged via squash.

Prepared head SHA: 3217f45e61
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-19 16:36:36 +08:00
Vincent Koc
e6743eb783 fix(agents): cancel prompt cache error bodies 2026-06-19 10:30:04 +02:00
Vincent Koc
dbd5689ea1 fix(agents): cancel model scan error bodies 2026-06-19 10:19:23 +02:00
Vincent Koc
44b0644e88 fix(slack): cancel followed redirect bodies 2026-06-19 10:12:35 +02:00
Vincent Koc
6aa85dfaa1 refactor(memory): drop unused host-sdk helpers 2026-06-19 16:04:00 +08:00
Vincent Koc
86b24ac2b2 fix(gateway): cancel pricing fetch bodies 2026-06-19 10:03:13 +02:00
Vincent Koc
d236612cc0 fix(sdk): refresh plugin API baseline hash 2026-06-19 09:59:02 +02:00
Vincent Koc
c3390f0bc6 fix(qa): keep whatsapp lease exhaustion visible 2026-06-19 09:58:03 +02:00
Vincent Koc
a6ac8de523 fix(openai): cancel OAuth preflight bodies 2026-06-19 09:53:35 +02:00
snowzlmbot
ca527aad9d fix(reply): clarify provider internal error copy (#94737)
Summary:
- The PR adds provider-internal/server_error classification in reply failure handling and regression tests for classifier output plus pre-reply external-channel copy.
- PR surface: Source +21, Tests +58. Total +79 across 3 files.
- Reproducibility: yes. source-reproducible. Current main sanitizes generic provider internal errors to a stab ... and conversation-state branches, so pre-reply chat failures can fall through to generic session-reset copy.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 8265fc71f3.
- Required merge gates passed before the squash merge.

Prepared head SHA: 8265fc71f3
Review: https://github.com/openclaw/openclaw/pull/94737#issuecomment-4747506983

Co-authored-by: snowzlm <snowzlm@noreply.codeberg.org>
Approved-by: vincentkoc
2026-06-19 07:52:51 +00:00
ZOOWH
3a435eebc0 fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match (#94810)
Summary:
- The PR changes Telegram sendChatAction 401 detection to trust structured Telegram `error_code` values before an unauthorized-text fallback and adds regression tests for false 401 suspension cases.
- PR surface: Source +14, Tests +90. Total +104 across 2 files.
- Reproducibility: yes. Source inspection shows current main and the latest release classify any rendered erro ...  before transient handling, matching the linked issue's structured 429 `retry_after=401` reproduction path.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 0ffee85d17.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0ffee85d17
Review: https://github.com/openclaw/openclaw/pull/94810#issuecomment-4748778567

Co-authored-by: 徐闻涵0668001344 <xu.wenhan1@xydigit.com>
Approved-by: vincentkoc
2026-06-19 07:51:40 +00:00
Vincent Koc
dfc5bd5fcc fix(crestodian): cancel gateway probe bodies 2026-06-19 09:32:50 +02:00
Vincent Koc
7cc66b5175 refactor(channels): drop unused bootstrap exports 2026-06-19 15:32:30 +08:00
Vincent Koc
fcec95ffd7 fix(signal): cancel status-only response bodies 2026-06-19 09:26:03 +02:00
Vincent Koc
e67f8ba459 fix(discord): cancel failed probe response bodies 2026-06-19 09:20:12 +02:00
Vincent Koc
33fa225f65 refactor(memory): drop unused host helpers 2026-06-19 15:13:27 +08:00
Vincent Koc
86a28636fa fix(update): cancel npm registry error bodies 2026-06-19 09:11:43 +02:00
Vincent Koc
90ba9fc864 fix(copilot): cancel model policy response bodies 2026-06-19 09:06:13 +02:00
Vincent Koc
f5419b5bb0 fix(openrouter): release music stream readers 2026-06-19 09:04:11 +02:00
Vincent Koc
14fd10f8f8 fix(qa): wait longer for live credential leases 2026-06-19 08:59:48 +02:00
Vincent Koc
38fefc5aaf refactor(runtime): drop unused exported helpers 2026-06-19 14:57:32 +08:00
Vincent Koc
ccdec2e294 test(tasks): pass temp root into registry helper 2026-06-19 08:55:56 +02:00
Vincent Koc
c79a5aa253 fix(onboard): cancel custom verification bodies 2026-06-19 08:55:56 +02:00
Vincent Koc
0dbac0d5f9 fix(tools): release bounded web response readers 2026-06-19 08:50:25 +02:00
Vincent Koc
b972feb3f7 fix(qa): honor telegram live ready timeout 2026-06-19 08:47:16 +02:00
Vincent Koc
3c01716c82 fix(agents): release proxy stream body readers 2026-06-19 08:43:51 +02:00
Vincent Koc
e802fb8a9f fix(agents): release provider error body readers 2026-06-19 08:36:39 +02:00
SpecialLeon
94b710ac00 test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases (#91656)
Summary:
- The PR expands `src/cron/parse.test.ts` with grouped `parseAbsoluteTimeMs` coverage for epoch, ISO timezone/offset, precision, whitespace, invalid-format, and cron example cases.
- PR surface: Tests +233. Total +233 across 1 file.
- Reproducibility: not applicable. this is a test coverage PR, not a runtime bug report with user steps. Source inspection confirms the requested parser coverage is still added only by this open PR path.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases

Validation:
- ClawSweeper review passed for head 69a49d9512.
- Required merge gates passed before the squash merge.

Prepared head SHA: 69a49d9512
Review: https://github.com/openclaw/openclaw/pull/91656#issuecomment-4657254372

Co-authored-by: 刘江0668001123 <liu.jiang2@xydigit.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-19 06:36:31 +00:00
Vincent Koc
ebee101d30 fix(qa): cancel Docker health probe bodies 2026-06-19 08:33:32 +02:00
JC
5697ab810e fix(tasks): deliver ACP completions to bound Discord threads (#89279)
Summary:
- The branch adds a bounded task-registry predicate and tests so successful delegated ACP parent-review comple ... with a Discord channel target and threadId send the parent-review terminal message directly to that thread.
- PR surface: Source +24, Tests +142. Total +166 across 2 files.
- Reproducibility: yes. at source level. Current main queues successful ACP parent-review completions through  ... annel/group owner keys, and the linked canonical issue includes matching Discord thread-bound ACP evidence.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 04ad66b23d.
- Required merge gates passed before the squash merge.

Prepared head SHA: 04ad66b23d
Review: https://github.com/openclaw/openclaw/pull/89279#issuecomment-4597994374

Co-authored-by: anyech <anyech@gmail.com>
2026-06-19 06:31:42 +00:00
David
cd98f195a7 fix(gateway): ignore stale abort markers for fresh chat events (#91013)
Summary:
- The branch stamps Gateway chat run registrations and abort markers with ordering metadata, uses freshness checks for chat projection suppression, and updates abort/restart/maintenance tests and related types.
- PR surface: Source +79, Tests +103. Total +182 across 13 files.
- Reproducibility: yes. source-level: on current main, seed abortedRuns for a client run id, register a same-k ...  end; the presence-only checks suppress both projections. I did not execute tests in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: ci: re-trigger checks against current main
- PR branch already contained follow-up commit before automerge: Merge upstream/main into stale-abort marker fix
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'upstream/main' into nex/91013-conflict-…

Validation:
- ClawSweeper review passed for head 6f13d6f7c2.
- Required merge gates passed before the squash merge.

Prepared head SHA: 6f13d6f7c2
Review: https://github.com/openclaw/openclaw/pull/91013#issuecomment-4640475472

Co-authored-by: nxmxbbd <32288+nxmxbbd@users.noreply.github.com>
2026-06-19 06:30:47 +00:00
Vincent Koc
be7d86ed80 fix(memory): abort remote response body reads 2026-06-19 08:25:53 +02:00
Vincent Koc
5d6ac23086 fix(agents): cancel local service probe bodies 2026-06-19 08:24:54 +02:00
Vincent Koc
f61ad70d3f fix(qa): surface live transport failure phases 2026-06-19 08:19:13 +02:00
Vincent Koc
05e70bd331 refactor(discord): drop unused setup account lister 2026-06-19 14:18:53 +08:00
Vincent Koc
8480ef3f86 fix(e2e): cancel readiness probe bodies 2026-06-19 08:18:01 +02:00
Vincent Koc
fc1bdecf08 fix(e2e): cancel ClickClack fixture bodies 2026-06-19 08:14:39 +02:00
Vincent Koc
a57e761f6b fix(e2e): cancel Open WebUI HTTP probe bodies 2026-06-19 08:11:11 +02:00
Vincent Koc
13be16d699 fix(openai): cancel oversized Codex image streams 2026-06-19 08:07:17 +02:00
Vincent Koc
257b533e85 fix(release): cancel Discord cleanup bodies 2026-06-19 08:07:00 +02:00
Vincent Koc
5939ab4c49 refactor(canvas): dedupe bundle hash inputs 2026-06-19 14:06:51 +08:00
Jesse Merhi
5db2f6c1fc Add stdout diagnostics OTEL log exporter
Adds stdout and both-mode diagnostics OTEL log export, with focused QA Lab smoke coverage and docs/config updates.

Prepared head SHA: efa2ef07ab
Verification: CI 27808480969 passed for the prepared head.
Reviewed-by: @jesse-merhi
2026-06-19 16:06:37 +10:00
Vincent Koc
afd9cb0c10 fix(github): cancel maintainer membership bodies 2026-06-19 08:02:41 +02:00
Vincent Koc
2e1e4167a9 fix(release): satisfy ClawHub retry lint 2026-06-19 13:57:56 +08:00
Vincent Koc
3bacf96ccc fix(codex): classify streamed subagent messages 2026-06-19 13:57:56 +08:00
Vincent Koc
433d8cbb2c fix(release): drain rate-limited ClawHub responses 2026-06-19 13:57:56 +08:00
Vincent Koc
37b2770071 fix(release): retry ClawHub release planning 2026-06-19 13:57:56 +08:00
Vincent Koc
e172f64f3f fix(codex): complete native subagent turns 2026-06-19 13:57:56 +08:00
Vincent Koc
8e66d7aad3 fix(release): cancel beta verifier status bodies 2026-06-19 07:57:19 +02:00
Vincent Koc
688ecb1655 fix(release): wrap bare Windows npm execpath 2026-06-19 07:55:33 +02:00
Vincent Koc
96dbd1c723 refactor(copilot): drop unused exported helpers 2026-06-19 13:55:08 +08:00
Glenn-Agent
2f12755498 fix: suggest close CLI commands (#91345)
Summary:
- The PR adds descriptor-backed CLI command suggestions for unknown root commands, wires them into Commander parse errors and early unowned-root diagnostics, and covers both paths with focused CLI tests.
- PR surface: Source +104, Tests +71. Total +175 across 5 files.
- Reproducibility: yes. for the behavior gap: current main's formatter and early unowned-root path emit generic diagnostics without closest-command hints, and the PR proof shows the after-fix CLI output.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: suppress suggestions for plugin policy diagnostics
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into fix/83999-cli-command…
- PR branch already contained follow-up commit before automerge: test: align agent model expectations
- PR branch already contained follow-up commit before automerge: test: restore unrelated agent test fixture

Validation:
- ClawSweeper review passed for head b98f5b59e6.
- Required merge gates passed before the squash merge.

Prepared head SHA: b98f5b59e6
Review: https://github.com/openclaw/openclaw/pull/91345#issuecomment-4646215016

Co-authored-by: Glenn-Agent <glenn_agent@163.com>
2026-06-19 05:54:14 +00:00
Vincent Koc
a37dd0210b fix(e2e): bound upgrade survivor probe retries 2026-06-19 07:52:44 +02:00
Sash Zats
17106b4844 fix(ios): clean up notification settings state (#91923)
Summary:
- The branch replaces iOS notification permission display-string state with a typed SettingsNotificationStatus ... n value, and opens the app notification Settings page with UIApplication.openNotificationSettingsURLString.
- PR surface: Other +51. Total +51 across 5 files.
- Reproducibility: yes. Current main has a source-level reproduction path where the Notifications settings act ... n display strings and opens the general app Settings URL instead of the notification-specific Settings URL.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 1a2fdeeac5.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1a2fdeeac5
Review: https://github.com/openclaw/openclaw/pull/91923#issuecomment-4669439195

Co-authored-by: Sash Zats <sash@zats.io>
2026-06-19 05:51:28 +00:00
cornna
93d0d2aedd fix(feishu): avoid axios interceptor internals (#89806)
Summary:
- The branch replaces Feishu's module-load Axios `handlers` reset with public request-interceptor registration and adds tests that throw on private handler access.
- PR surface: Source +7, Tests +48. Total +55 across 2 files.
- Reproducibility: yes. for the source/dependency boundary: current main still writes `interceptors.request.ha ... l on that access before the production change. No live authenticated Feishu request failure was reproduced.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head b87083193b.
- Required merge gates passed before the squash merge.

Prepared head SHA: b87083193b
Review: https://github.com/openclaw/openclaw/pull/89806#issuecomment-4611809953

Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-19 05:50:54 +00:00
Vincent Koc
82ae81f3bf fix(e2e): time out package url response bodies 2026-06-19 07:45:52 +02:00
Vincent Koc
2db37c2cd0 refactor(copilot): drop unused permission policy helpers 2026-06-19 13:43:27 +08:00
Vincent Koc
6370f2023a fix(release): cancel ClawHub probe bodies 2026-06-19 07:42:04 +02:00
Vincent Koc
2dbbef46bb fix(e2e): cancel Open WebUI probe body reads 2026-06-19 07:37:28 +02:00
bbblending
df261fabb3 fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468) (#94612)
Summary:
- The PR wires the macOS Dashboard and Canvas WKWebViews to WKUIDelegate and presents NSOpenPanel for HTML file inputs.
- PR surface: Other +61. Total +61 across 3 files.
- Reproducibility: yes. at source level: current main renders the affected file inputs while the macOS Dashboa ... fore-fix packaged macOS app in this read-only review, but the after-fix screenshots show the real app path.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 4f477c4ed0.
- Required merge gates passed before the squash merge.

Prepared head SHA: 4f477c4ed0
Review: https://github.com/openclaw/openclaw/pull/94612#issuecomment-4743165861

Co-authored-by: bbblending <li.mingkang@xydigit.com>
2026-06-19 05:32:45 +00:00
clawsweeper[bot]
c041a45ece chore: forward-port alpha release fixes
Forward-port Tideclaw alpha validation fixes from v2026.6.19-alpha.1 prep: Kova timeout evidence and Telegram QA command markers.
2026-06-19 05:32:10 +00:00
Vincent Koc
089f8c7fb5 fix(e2e): cancel plugin preflight body reads 2026-06-19 07:28:00 +02:00
Vincent Koc
712e69dd74 fix(e2e): honor gateway network client deadline 2026-06-19 07:23:24 +02:00
Vincent Koc
13aaece8b3 refactor(deepinfra): drop unused provider config helper 2026-06-19 13:23:09 +08:00
Vincent Koc
32ee308f55 fix(e2e): cancel RPC RTT probe bodies 2026-06-19 07:21:40 +02:00
Vincent Koc
5776b9b4e6 fix(e2e): cancel kitchen probe body reads on abort 2026-06-19 07:16:35 +02:00
Fabian.Xu
6368c1173c fix: guard tool event callbacks (AI-assisted) (#81696)
Summary:
- This PR wraps embedded-agent tool-handler onExecutionPhase and per-run onAgentEvent emissions in best-effort warning guards and adds regression tests for throwing and rejecting callbacks.
- PR surface: Source +31, Tests +44. Total +75 across 2 files.
- Reproducibility: yes. Current main directly invokes the relevant callbacks in the tool-start and tool-event  ... sync observer can leak unless guarded; I did not run a failing current-main repro in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 65de17d9e0.
- Required merge gates passed before the squash merge.

Prepared head SHA: 65de17d9e0
Review: https://github.com/openclaw/openclaw/pull/81696#issuecomment-4448200659

Co-authored-by: xuyi1243 <maginaxwhz@gmail.com>
2026-06-19 05:13:35 +00:00
Vincent Koc
dc9b1d5159 fix(e2e): cancel chat-tools response reads on timeout 2026-06-19 07:11:07 +02:00
clawsweeper[bot]
308fb97f7a feat(slack): log INFO receipt for inbound app_mention events (#94790)
Summary:
- The branch adds a Slack subsystem INFO receipt formatter/logger for accepted non-DM app_mention events before dispatch, plus direct log tests and a test-harness team id.
- PR surface: Source +37, Tests +81. Total +118 across 3 files.
- Reproducibility: yes. from source inspection. Current main and v2026.6.8 route accepted Slack app_mention ev ... andleSlackMessage without a per-inbound INFO receipt, while Telegram emits an inbound line before dispatch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: feat(slack): log INFO receipt for inbound app_mention events

Validation:
- ClawSweeper review passed for head b174201e0a.
- Required merge gates passed before the squash merge.

Prepared head SHA: b174201e0a
Review: https://github.com/openclaw/openclaw/pull/94790#issuecomment-4748509343

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
2026-06-19 05:10:54 +00:00
Vincent Koc
e498fc8c3b fix(tooling): cancel labeler response bodies on timeout 2026-06-19 07:08:03 +02:00
Vincent Koc
b794d7fb58 fix(clickclack): reconnect after websocket errors 2026-06-19 07:07:25 +02:00
Vincent Koc
bc7c2baa5c fix(openai): suppress realtime startup close callbacks 2026-06-19 07:07:25 +02:00
Vincent Koc
46c42d4a0d refactor(providers): drop unused model defaults helpers 2026-06-19 13:05:48 +08:00
Vincent Koc
3a82bf5766 fix(tooling): cancel clawtributor avatar body reads 2026-06-19 07:02:27 +02:00
Vincent Koc
5e7a0b1558 refactor(providers): drop unused onboarding wrappers 2026-06-19 12:52:46 +08:00
Vincent Koc
38ebc24f77 fix(github): cancel gh-read bodies on timeout 2026-06-19 06:52:19 +02:00
Vincent Koc
af3c10626c test(plugin): align prerelease rpc lane contract 2026-06-19 06:49:46 +02:00
Vincent Koc
324ad548a8 fix(release): keep ClawHub verification bodies timed 2026-06-19 06:36:55 +02:00
Vincent Koc
e552f97866 refactor(irc): drop unused text splitter 2026-06-19 12:36:41 +08:00
Vincent Koc
259f071a93 fix(signal): report receive websocket pre-open closes 2026-06-19 06:34:46 +02:00
liuhao1024
a5190f7d4a fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727) (#94729)
Summary:
- The PR adds `curl` to the bundled Trello skill's `metadata.openclaw.requires.bins` entry.
- PR surface: Docs 0. Total 0 across 1 file.
- Reproducibility: yes. at source level. Current main and v2026.6.8 declare only `jq` for Trello while the skill body uses `curl`, and the shared requirement evaluator checks only declared bins.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 83ae5e8bef.
- Required merge gates passed before the squash merge.

Prepared head SHA: 83ae5e8bef
Review: https://github.com/openclaw/openclaw/pull/94729#issuecomment-4747397470

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-19 04:33:38 +00:00
Vincent Koc
a619518ebe fix(e2e): keep cross-os response reads timed 2026-06-19 06:31:32 +02:00
Vincent Koc
ea0b0ad0a0 refactor(line): drop unused media kind detector 2026-06-19 12:30:06 +08:00
nas
fb25f29638 fix(codex): bound turn/start text when context budget is non-positive (#94756)
Summary:
- The PR updates Codex context projection fitting so non-positive context budgets still return turn/start text within the app-server input cap while preserving the current user request tail.
- PR surface: Source +23, Tests +87. Total +110 across 2 files.
- Reproducibility: yes. Current main is source-reproducible: when `beforeContext.length + afterContext.length  ... ll-over-limit text; the linked diagnostic also shows the real Codex app-server rejects that pre-fix string.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 1510a3d13a.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1510a3d13a
Review: https://github.com/openclaw/openclaw/pull/94756#issuecomment-4747889774

Co-authored-by: Anas <anaselghoudane@gmail.com>
2026-06-19 04:28:27 +00:00
nas
300f5e8590 fix(sessions): preserve Media* index alignment when reading user-turn fields (#94257)
Summary:
- Merged fix(sessions): preserve Media* index alignment when reading user-turn fields after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge branch 'main' into fix/media-types-index-alignment

Validation:
- ClawSweeper review passed for head 62db437d10.
- Required merge gates passed before the squash merge.

Prepared head SHA: 62db437d10
Review: https://github.com/openclaw/openclaw/pull/94257#issuecomment-4748486369

Co-authored-by: Anas <anaselghoudane@gmail.com>
Co-authored-by: nas <156536069+Nas01010101@users.noreply.github.com>
Approved-by: vincentkoc
2026-06-19 04:28:14 +00:00
Vincent Koc
f144899219 test(diffs): format viewer client coverage 2026-06-19 06:25:04 +02:00
Vincent Koc
d095e2a4f5 refactor(mattermost): drop unused draft preview helpers 2026-06-19 12:24:35 +08:00
liuhao1024
8aaa4bf3ef test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915) (#92873)
Summary:
- Merged test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915) after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(diffs): use real FileContents shape in hydrateProps fixtures

Validation:
- ClawSweeper review passed for head a09ee50584.
- Required merge gates passed before the squash merge.

Prepared head SHA: a09ee50584
Review: https://github.com/openclaw/openclaw/pull/92873#issuecomment-4748453452

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Approved-by: vincentkoc
2026-06-19 04:19:54 +00:00
Vincent Koc
06b6f7055b fix(e2e): keep clickclack fixture body reads timed 2026-06-19 06:19:21 +02:00
Vincent Koc
de95726177 fix(googlechat): format approval capability import 2026-06-19 06:18:38 +02:00
Vincent Koc
e91c17947b test(cron): cover force run mode 2026-06-19 06:18:38 +02:00
Vincent Koc
a25c64a4e4 refactor(diffs): drop unused language hint filter 2026-06-19 12:17:46 +08:00
Stellar鱼
8888bca752 test(browser): cover action-input CLI request bodies (#92574)
Summary:
- The branch adds Vitest coverage for browser action-input CLI request bodies across element, navigation/resize, fill/evaluate, and upload paths, plus blank-ref validation.
- PR surface: Tests +278. Total +278 across 4 files.
- Reproducibility: yes. for a source-level coverage gap: current main exposes the browser action-input command ... isting tests still lack broad success-path request-body assertions. This is not a runtime bug reproduction.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(browser): cover click-coords action body

Validation:
- ClawSweeper review passed for head c070a8d51b.
- Required merge gates passed before the squash merge.

Prepared head SHA: c070a8d51b
Review: https://github.com/openclaw/openclaw/pull/92574#issuecomment-4697124920

Co-authored-by: Stellar鱼 <2182712990@qq.com>
Co-authored-by: yu-xin-c <2182712990@qq.com>
2026-06-19 04:17:29 +00:00
Hidetsugu55
50a4bb00e5 fix(gateway): never return an empty chat.history transcript (#92383)
Summary:
- The PR changes gateway chat-history byte-budget fallback behavior to return a small metadata-free unavailable sentinel instead of an empty transcript, with focused budget tests.
- PR surface: Source +20, Tests +73. Total +93 across 2 files.
- Reproducibility: yes. Source inspection shows current main reaches `messages: []` when the full history, las ... d copied oversized placeholder all exceed `maxBytes`; I did not run tests because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test: access __openclaw via bracket notation for no-underscore-dangle

Validation:
- ClawSweeper review passed for head f2fa246ab7.
- Required merge gates passed before the squash merge.

Prepared head SHA: f2fa246ab7
Review: https://github.com/openclaw/openclaw/pull/92383#issuecomment-4688688923

Co-authored-by: Hidetsugu55 <183473679+Hidetsugu55@users.noreply.github.com>
2026-06-19 04:17:02 +00:00
Vincent Koc
de17d5b9ef fix(scripts): fail RPC RTT on websocket pre-open close 2026-06-19 06:16:22 +02:00
Voscko
6a0c3eaf78 fix(android): group settings by intent (#94539)
Summary:
- The PR reorganizes the Android Settings home rows into titled intent sections and adds ShellScreen logic tests for section title mapping and section ordering.
- PR surface: Other +106. Total +106 across 2 files.
- Reproducibility: not applicable. this is a UI organization cleanup rather than a bug report. The relevant ve ... ion path is the before/after Android emulator screenshot proof plus source comparison against current main.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head da9bf5c5b5.
- Required merge gates passed before the squash merge.

Prepared head SHA: da9bf5c5b5
Review: https://github.com/openclaw/openclaw/pull/94539#issuecomment-4741795253

Co-authored-by: Tosko4 <tosko4@gmail.com>
2026-06-19 04:15:19 +00:00
mushuiyu_xydt
78f948f768 fix(tui): show 0 not ? for fresh-session context tokens in footer (#94337)
Summary:
- The PR extends TUI session info to carry `totalTokensFresh`, maps fresh missing totals to `0`, and adds a focused regression test for the footer merge path.
- PR surface: Source +15, Tests +38. Total +53 across 4 files.
- Reproducibility: yes. at source level: `chat.history` returns session info with `totalTokensFresh`, but curr ...  `null` before footer formatting. I did not run local tests or a live TUI session in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge branch 'main' into fix/followup-93798

Validation:
- ClawSweeper review passed for head 43657b52c8.
- Required merge gates passed before the squash merge.

Prepared head SHA: 43657b52c8
Review: https://github.com/openclaw/openclaw/pull/94337#issuecomment-4737123127

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
Co-authored-by: mushuiyu_xydt <yang.haoyu@xydigit.com>
2026-06-19 04:10:08 +00:00
Vincent Koc
975340fbd5 fix(audit): cancel stalled advisory body reads 2026-06-19 06:09:34 +02:00
Vincent Koc
7570831ee1 refactor(extensions): drop unused compatibility aliases 2026-06-19 12:08:51 +08:00
Vincent Koc
f2a83a7a71 fix(transcription): preserve websocket session errors 2026-06-19 06:05:24 +02:00
Vincent Koc
d9c66b9c6d fix(e2e): bound upgrade survivor probe body reads 2026-06-19 06:02:31 +02:00
Steven
39328ed692 fix(skills): retarget stale plugin skill symlinks (#86719)
Summary:
- The PR retargets stale generated plugin-skill symlinks when their old target disappeared and adds regression coverage for that case.
- PR surface: Source +11, Tests +17. Total +28 across 2 files.
- Reproducibility: no. high-confidence current-main failure was run in this read-only review. The linked issue ... ased-build filesystem state and source inspection confirms the runtime publisher path that this PR changes.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'upstream/main' into fix/plugin-skill-st…
- PR branch already contained follow-up commit before automerge: fix(skills): unlink generated plugin skill symlinks

Validation:
- ClawSweeper review passed for head 94a9765735.
- Required merge gates passed before the squash merge.

Prepared head SHA: 94a9765735
Review: https://github.com/openclaw/openclaw/pull/86719#issuecomment-4539047343

Co-authored-by: Steven Palmer <palmer.e.steven@gmail.com>
2026-06-19 04:02:05 +00:00
Josh Lehman
8662b9de54 refactor: route sdk session compatibility through accessor (#89904) 2026-06-18 21:00:05 -07:00
Dirk
6504237900 fix(note): prevent clack from re-breaking copy-sensitive tokens (#94746)
Summary:
- The PR widens the virtual Clack output columns for wrapped terminal notes and adds a rendered-output regression test for copy-sensitive session-lock paths.
- PR surface: Source +8, Tests +28. Total +36 across 2 files.
- Reproducibility: yes. Current source routes session lock paths through `note()`, and the pinned Clack note renderer hard-wraps final content from `getColumns(output) - 6` after OpenClaw's first wrapping pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(note): add rendered-output regression test for copy-sensitive to…

Validation:
- ClawSweeper review passed for head b17a4ff571.
- Required merge gates passed before the squash merge.

Prepared head SHA: b17a4ff571
Review: https://github.com/openclaw/openclaw/pull/94746#issuecomment-4747714518

Co-authored-by: Dirk <0668000837@xydigit.com>
2026-06-19 03:59:09 +00:00
Vincent Koc
fb69db6365 refactor(extensions): drop unused approval and tunnel helpers 2026-06-19 11:59:05 +08:00
Vincent Koc
845ad1cf71 fix(tooling): timeout transitive manifest packuments 2026-06-19 05:55:00 +02:00
jincheng-xydt
356a199bd4 fix: default cron runMode to "due" instead of "force" (#94270) (#94453)
* fix: default cron runMode to 'due' instead of 'force'

When the runMode parameter is omitted from a cron 'run' action,
the default value now respects schedule guards ('due') instead
of bypassing them ('force'). This prevents unintended execution
of scheduled jobs outside their configured time windows.

Fixes #94270

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

* test: update runMode expectations for default 'due' (#94270)

* ci: trigger re-evaluation of real behavior proof

* fix(cron): document due-by-default agent runs

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

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-18 23:53:17 -04:00
Josh Lehman
f1cab04966 refactor: route SDK session compatibility through seam (#89203) 2026-06-18 20:52:46 -07:00
Yuval Dinodia
87854e841e fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply (#93823)
Summary:
- The PR changes the WhatsApp auto-reply first-media failure fallback to resend the saved leading caption chunk and adds a multi-chunk regression test for that failure path.
- PR surface: Source 0, Tests +26. Total +26 across 2 files.
- Reproducibility: yes. Source inspection of current main gives a deterministic path: the first chunk is shift ... fallback shifts `remainingText` again before checking `caption`; this read-only review did not rerun tests.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head b609e44654.
- Required merge gates passed before the squash merge.

Prepared head SHA: b609e44654
Review: https://github.com/openclaw/openclaw/pull/93823#issuecomment-4724923171

Co-authored-by: yetval <yetvald@gmail.com>
2026-06-19 03:52:43 +00:00
lizeyu-xydt
508e3bf413 fix: #80507 show dry-run output for message send/poll (#94684)
Summary:
- The branch changes `formatMessageCliText` to render dry-run message output from `result.dryRun` instead of only `handledBy === "dry-run"`.
- PR surface: Source 0. Total 0 across 1 file.
- Reproducibility: yes. source-reproducible. The linked issue has captured CLI output, and current main shows  ... e the formatter still checks `handledBy === "dry-run"`; I did not execute the CLI in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head dce6d6a0d3.
- Required merge gates passed before the squash merge.

Prepared head SHA: dce6d6a0d3
Review: https://github.com/openclaw/openclaw/pull/94684#issuecomment-4746101038

Co-authored-by: lizeyu-xydt <li.zeyu@xydigit.com>
2026-06-19 03:51:50 +00:00
Vincent Koc
825188cb6a refactor(xai): drop unused sync tool auth helper 2026-06-19 11:48:55 +08:00
Vincent Koc
36bfe77db1 fix(github): bound guard response bodies 2026-06-19 05:47:19 +02:00
Vincent Koc
def4c995ac fix(ui): isolate gateway callback errors 2026-06-19 05:43:45 +02:00
Vincent Koc
f91350485c refactor(voice-call): drop unused voice lookup helpers 2026-06-19 11:43:26 +08:00
Vincent Koc
d91766e5e1 fix(release): bound ClawHub trusted publisher reads 2026-06-19 05:38:59 +02:00
sutra
dae06a203f fix: add self-knowledge docs rule to system prompt (#90882)
Summary:
- This PR replaces the generated Documentation prompt wording with self-knowledge docs-authority guidance and updates prompt tests plus the system-prompt docs.
- PR surface: Source 0, Tests +27, Docs +6. Total +33 across 4 files.
- Reproducibility: yes. from source for the prompt gap: current main and v2026.6.8 have only broad docs-first  ... ledge failure example. I did not run a fresh current-main live model conversation in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: strengthen self-knowledge docs prompt
- PR branch already contained follow-up commit before automerge: test: narrow cli prompt tool assertion
- PR branch already contained follow-up commit before automerge: fix: condense self-knowledge docs prompt
- PR branch already contained follow-up commit before automerge: fix: clarify self-knowledge docs authority
- PR branch already contained follow-up commit before automerge: Merge branch 'main' into sutrah/self-knowledge-docs-prompt

Validation:
- ClawSweeper review passed for head 88a7db5d2a.
- Required merge gates passed before the squash merge.

Prepared head SHA: 88a7db5d2a
Review: https://github.com/openclaw/openclaw/pull/90882#issuecomment-4637990339

Co-authored-by: Sutra Hsing <sutrahsing@163.com>
Co-authored-by: sutra <sutrahsing@163.com>
2026-06-19 03:38:36 +00:00
Vincent Koc
52948a1726 refactor(file-transfer): drop unused fs error classifier 2026-06-19 11:34:41 +08:00
Vincent Koc
88c92922e1 fix(release): bound npm registry verification bodies 2026-06-19 05:33:16 +02:00
Vincent Koc
ae6e1fa4d2 fix(sdk): settle connect after observer errors 2026-06-19 05:27:57 +02:00
Vincent Koc
f1c0d5f06f refactor(sessions): drop unused thread parent helper 2026-06-19 11:27:23 +08:00
Vincent Koc
b28fda9ef8 fix(tooling): bound RPC RTT readiness bodies 2026-06-19 05:20:17 +02:00
Vincent Koc
09427aa760 fix(gateway): contain lifecycle callback errors 2026-06-19 05:15:20 +02:00
Vincent Koc
107904c2c5 refactor(pairing): drop unused direct lookup helpers 2026-06-19 11:14:22 +08:00
Vincent Koc
6c82a9fb18 fix(release): bound GitHub API body reads 2026-06-19 05:13:48 +02:00
kevinlin-openai
741f7080a7 feat(codex): support app-server secret refs (#94324)
Co-authored-by: kevinlin-openai <kevin@dendron.so>
2026-06-18 20:10:11 -07:00
Vincent Koc
a1b7118d0f refactor(infra): drop unused apns store wrappers 2026-06-19 11:06:30 +08:00
Vincent Koc
ca6d52e0e8 fix(e2e): bound bundled readyz diagnostics 2026-06-19 05:00:38 +02:00
Vincent Koc
4c55e04e49 refactor(infra): drop unused web push id helpers 2026-06-19 10:59:13 +08:00
Vincent Koc
8d596aa651 fix(sdk): reject work after client close 2026-06-19 04:54:50 +02:00
Vincent Koc
1385da8d3f refactor(infra): drop unused clawhub endpoint clients 2026-06-19 10:53:19 +08:00
Vincent Koc
ad715dfdc9 fix(e2e): abort kitchen sink retry waits 2026-06-19 04:49:43 +02:00
Vincent Koc
d1ab308f5c refactor(infra): trim dead archive facade exports 2026-06-19 10:44:19 +08:00
Vincent Koc
aaceaf8e7c fix(e2e): clear streaming host command timers 2026-06-19 04:37:43 +02:00
Vincent Koc
1492f9906a fix(qa-lab): keep workspace skills in sandbox 2026-06-19 04:33:18 +02:00
Vincent Koc
9ceb970a06 fix(e2e): clear npm update spawn timers 2026-06-19 04:31:34 +02:00
Vincent Koc
c33007ef58 fix(release): scope MiniMax live model gate 2026-06-19 10:26:02 +08:00
Vincent Koc
09a159c913 refactor(config): remove dead quota maintenance facade 2026-06-19 10:25:50 +08:00
Vincent Koc
eedb6678f1 fix(e2e): extend kitchen rpc watchdog 2026-06-19 04:21:10 +02:00
Vincent Koc
459bcd6198 fix(e2e): clean npm update guest scripts 2026-06-19 04:20:42 +02:00
Vincent Koc
1bbc3b6cb6 refactor(config): remove dead async session target resolver 2026-06-19 10:19:41 +08:00
Vincent Koc
6325a8b5f4 fix(e2e): clean Parallels guest temp scripts 2026-06-19 04:13:03 +02:00
Vincent Koc
5805af9dc4 fix(e2e): defer codex live profile exports 2026-06-19 04:09:10 +02:00
Vincent Koc
42bcb3ecb0 refactor(config): remove dead session schema exports 2026-06-19 10:08:24 +08:00
Thomas Krohnfuß
b48238aa88 feat(commands): add /name to rename the current session from chat (#88581)
* Add /name chat command to rename the current session

Adds a `/name <title>` slash command so users can name or rename the
current session directly from any chat channel, instead of only through
the web/admin session manager. This keeps parallel sessions easy to tell
apart from within the chat flow.

Behaviour:
- `/name <title>` sets the session label, reusing the canonical
  `parseSessionLabel` validation (trim, non-empty, max 512 chars) and the
  same cross-store uniqueness rule enforced by the web `sessions.patch`
  path, so chat naming behaves identically to the session manager.
- `/name` with no argument shows the current name plus a locally derived
  `deriveSessionTitle` suggestion without mutating anything (no LLM).
- Only authorized senders can rename (rejectUnauthorizedCommand), matching
  /goal. The label surfaces everywhere sessions.list is shown (TUI, web,
  CLI, MCP).

The handler resolves the session via resolveSessionStoreEntry so renames
land on the canonical entry even when the store still holds a legacy or
case-folded key alias, and excludes those aliases from the uniqueness scan
to avoid false conflicts. Failed renames skip the store write.

Registers the command in commands-registry.shared.ts and the handler in
loadCommandHandlers, documents it in docs/tools/slash-commands.md, and adds
unit tests covering rename, no-arg suggestion, duplicate-label rejection,
unauthorized senders, disabled text commands, and persisted-name re-read.

Part of the chat-native session naming feature (follows the web in-chat
rename PR). Relates to openclaw#85502 and openclaw#54397.

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

* fix(name): seed native sessions and persist renames via canonical key

Address Codex review on PR #88581:
- Fall back to the in-memory params.sessionEntry when the store has no row
  yet, so a brand-new native slash session can be named from its first
  /name command instead of failing with 'no active session to name'.
- Persist the rename through resolved.normalizedKey and drop legacy/
  case-folded alias keys (mirroring persistResolvedSessionEntry) so the
  canonical entry is updated and sessions.list stops surfacing the stale
  alias row.

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

* fix(name): emit session metadata changes

Route successful /name renames through the shared command session metadata seam so subscribed session lists receive sessions.changed like /goal.

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

* feat(commands): add /name to rename the current session from chat

* fix(docs): document the /name slash command

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Agent <agent@example.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-19 10:04:28 +08:00
Vincent Koc
dc980273e3 fix(e2e): align live timeout wrappers 2026-06-19 04:01:43 +02:00
Vincent Koc
74f90885f3 fix(release): unblock beta validation 2026-06-19 10:01:28 +08:00
Vincent Koc
236a0c8fe0 test(auto-reply): inline dead group mention fixture 2026-06-19 10:01:17 +08:00
Vincent Koc
b306745f64 fix(e2e): forward plugin lifecycle poll interval 2026-06-19 03:56:03 +02:00
Stellar鱼
20dd2be0f2 fix(agents): skip auth gate for CLI-owned transport (#88551)
* fix(agents): skip fallback auth gate for CLI runtimes

* fix(agents): skip auth gate for CLI-owned transport

* fix(agents): skip auth gate for CLI-owned transport

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-19 09:54:15 +08:00
Vincent Koc
5b030418c1 refactor(sessions): drop dead transcript tree wrappers 2026-06-19 09:47:31 +08:00
807 changed files with 20703 additions and 6765 deletions

4
.github/labeler.yml vendored
View File

@@ -171,6 +171,10 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1686,7 +1686,8 @@ jobs:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }}
OPENCLAW_LIVE_MAX_MODELS: "6"
OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }}
OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }}
OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000"
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -2000,7 +2001,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2303,7 +2304,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -45,7 +45,7 @@ on:
kova_ref:
description: openclaw/Kova Git ref to install
required: false
default: b63b6f9e20efb23641df00487e982230d81a90ac
default: 4f146016583018bad9e24f8e64a6af5f963bb7ee
type: string
dispatch_id:
description: Optional parent workflow dispatch identifier
@@ -66,6 +66,7 @@ env:
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
KOVA_SCENARIO_TIMEOUT_MS: "300000"
jobs:
kova:
@@ -98,7 +99,7 @@ jobs:
live: "true"
include_filters: "scenario:agent-cold-warm-message"
env:
KOVA_REF: ${{ inputs.kova_ref || 'b63b6f9e20efb23641df00487e982230d81a90ac' }}
KOVA_REF: ${{ inputs.kova_ref || '4f146016583018bad9e24f8e64a6af5f963bb7ee' }}
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
@@ -291,6 +292,7 @@ jobs:
--auth "$AUTH_MODE"
--parallel 1
--repeat "$repeat"
--timeout-ms "$KOVA_SCENARIO_TIMEOUT_MS"
--report-dir "$REPORT_DIR"
--execute
--json
@@ -361,6 +363,7 @@ jobs:
- Kova repository: ${KOVA_REPOSITORY}
- Kova ref: ${KOVA_REF}
- Kova profile: ${PROFILE}
- Kova scenario timeout: ${KOVA_SCENARIO_TIMEOUT_MS}ms
- Lane auth: ${AUTH_MODE}
- Lane model: ${PERFORMANCE_MODEL_ID}
- Lane repeat: ${repeat}

View File

@@ -532,6 +532,7 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail
@@ -624,6 +625,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
@@ -721,6 +723,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
@@ -815,6 +818,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"

View File

@@ -898,32 +898,38 @@ private fun SettingsShellScreen(
ProfilePanel(displayName = displayName.ifBlank { "OpenClaw" }, onClick = { onRouteChange(SettingsRoute.Profile) })
}
item {
SettingsGroup(
rows =
listOf(
SettingsRow("Profile", displayName.ifBlank { "Local device" }, Icons.Default.Person, route = SettingsRoute.Profile),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),
onOpen = onRouteChange,
val settingsRows =
listOf(
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
)
settingsSections(settingsRows).forEach { section ->
item {
SettingsSectionTitle(section.title)
}
item {
SettingsGroup(rows = section.rows, onOpen = onRouteChange)
}
}
item {
SettingsSectionTitle("Account")
}
item {
SettingsGroup(
rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)),
@@ -1057,7 +1063,7 @@ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
else -> null
}
private data class SettingsRow(
internal data class SettingsRow(
val title: String,
val value: String,
val icon: ImageVector,
@@ -1065,6 +1071,65 @@ private data class SettingsRow(
val route: SettingsRoute? = null,
)
internal data class SettingsSection(
val title: String,
val rows: List<SettingsRow>,
)
internal fun settingsSections(rows: List<SettingsRow>): List<SettingsSection> =
settingsSectionOrder.mapNotNull { title ->
val sectionRows = rows.filter { row -> row.route?.let(::settingsSectionTitleForRoute) == title }
if (sectionRows.isEmpty()) null else SettingsSection(title = title, rows = sectionRows)
}
private val settingsSectionOrder =
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
)
internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
when (route) {
SettingsRoute.Gateway,
SettingsRoute.NodesDevices,
SettingsRoute.Channels,
-> "Connection"
SettingsRoute.Agents,
SettingsRoute.Approvals,
SettingsRoute.CronJobs,
SettingsRoute.Usage,
SettingsRoute.Skills,
SettingsRoute.Dreaming,
-> "Agents & automation"
SettingsRoute.Voice,
SettingsRoute.Canvas,
SettingsRoute.Notifications,
SettingsRoute.PhoneCapabilities,
-> "Phone context & privacy"
SettingsRoute.Profile,
SettingsRoute.Appearance,
SettingsRoute.About,
-> "Profile & device"
SettingsRoute.Health -> "Diagnostics"
SettingsRoute.Home -> "Diagnostics"
}
@Composable
private fun SettingsSectionTitle(title: String) {
Text(
text = title.uppercase(),
style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
)
}
@Composable
private fun ProfilePanel(
displayName: String,

View File

@@ -7,6 +7,8 @@ import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -155,7 +157,46 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications))
assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance))
assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health))
}
@Test
fun settingsSectionsPreserveMeaningfulOrder() {
val sections =
settingsSections(
listOf(
settingsRow(SettingsRoute.Voice),
settingsRow(SettingsRoute.Agents),
settingsRow(SettingsRoute.Gateway),
settingsRow(SettingsRoute.Appearance),
settingsRow(SettingsRoute.Health),
),
)
assertEquals(
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
),
sections.map { it.title },
)
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route)
}

View File

@@ -12,7 +12,7 @@ report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchExtension/Sources/**
- WatchApp/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -3,6 +3,7 @@
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"targets": [
{
"target": "OpenClaw",
@@ -11,7 +12,8 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS"]
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"target": "OpenClawShareExtension",
@@ -20,7 +22,8 @@
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": []
"capabilities": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"target": "OpenClawActivityWidget",
@@ -39,15 +42,6 @@
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -7,12 +7,11 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -7,13 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -101,6 +101,7 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -155,7 +156,8 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
- `ai.openclawfoundation.app.watchkitapp.extension`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.

View File

@@ -41,5 +41,7 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
includeDeviceIdentity: false)
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
}
do {

View File

@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
@@ -19,7 +19,6 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -53,8 +53,7 @@ struct SettingsProTab: View {
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State var notificationStatusText = "Checking"
@State var notificationActionText = "Request Access"
@State var notificationStatus: SettingsNotificationStatus = .checking
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false

View File

@@ -65,7 +65,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
color: self.notificationStatus.color)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
@@ -157,7 +157,7 @@ extension SettingsProTab {
gatewayConnected: self.gatewayDiagnosticConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
notificationStatusText: self.notificationStatusText)
notificationsAllowed: self.notificationStatus == .allowed)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
}
@@ -422,8 +422,8 @@ extension SettingsProTab {
}
func handleNotificationAction() {
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
self.openSystemSettings()
if self.notificationStatus.shouldOpenNotificationSettings {
self.openNotificationSettings()
return
}
@@ -434,28 +434,14 @@ extension SettingsProTab {
.sound,
])) ?? false
await MainActor.run {
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
self.notificationStatus = granted ? .allowed : .notAllowed
}
}
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self.notificationStatusText = "Allowed"
self.notificationActionText = "Open System Settings"
case .denied:
self.notificationStatusText = "Not Allowed"
self.notificationActionText = "Open System Settings"
case .notDetermined:
self.notificationStatusText = "Not Set"
self.notificationActionText = "Request Access"
@unknown default:
self.notificationStatusText = "Unknown"
self.notificationActionText = "Open System Settings"
}
self.notificationStatus = SettingsNotificationStatus(status)
}
func persistGatewayToken(_ value: String) {
@@ -476,8 +462,8 @@ extension SettingsProTab {
instanceId: instanceId)
}
func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
func openNotificationSettings() {
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
UIApplication.shared.open(url)
}
@@ -777,4 +763,12 @@ extension SettingsProTab {
case .always: "Always"
}
}
var notificationStatusText: String {
self.notificationStatus.text
}
var notificationActionText: String {
self.notificationStatus.actionTitle
}
}

View File

@@ -492,7 +492,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approvals and event alerts from OpenClaw.",
value: self.notificationStatusText,
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
color: self.notificationStatus.color)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
@@ -501,7 +501,7 @@ extension SettingsProTab {
} label: {
Label(
self.notificationActionText,
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
systemImage: self.notificationStatus.actionIcon)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)

View File

@@ -1,6 +1,7 @@
import Darwin
import OpenClawKit
import SwiftUI
import UserNotifications
enum SettingsRoute: Hashable {
case gateway
@@ -65,6 +66,63 @@ struct SettingsApprovalRow: View {
}
}
enum SettingsNotificationStatus: Equatable {
case checking
case allowed
case notAllowed
case notSet
case unknown
init(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self = .allowed
case .denied:
self = .notAllowed
case .notDetermined:
self = .notSet
@unknown default:
self = .unknown
}
}
var text: String {
switch self {
case .checking: "Checking"
case .allowed: "Allowed"
case .notAllowed: "Not Allowed"
case .notSet: "Not Set"
case .unknown: "Unknown"
}
}
var actionTitle: String {
switch self {
case .notSet, .checking:
"Request Access"
case .allowed, .notAllowed, .unknown:
"Open System Settings"
}
}
var actionIcon: String {
self == .allowed ? "gear" : "bell.badge"
}
var color: Color {
self == .allowed ? OpenClawBrand.ok : .secondary
}
var shouldOpenNotificationSettings: Bool {
switch self {
case .allowed, .notAllowed, .unknown:
true
case .checking, .notSet:
false
}
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable
@@ -77,13 +135,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationStatusText: String) -> [SettingsDiagnosticIssue]
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
{
var issues: [SettingsDiagnosticIssue] = []
if !gatewayConnected { issues.append(.gatewayOffline) }
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
return issues
}
@@ -91,13 +149,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationStatusText: String) -> Int
notificationsAllowed: Bool) -> Int
{
self.issues(
gatewayConnected: gatewayConnected,
discoveredGatewayCount: discoveredGatewayCount,
talkConfigLoaded: talkConfigLoaded,
notificationStatusText: notificationStatusText).count
notificationsAllowed: notificationsAllowed).count
}
static func timestamp(_ date: Date) -> String {

View File

@@ -62,6 +62,7 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -78,6 +78,8 @@
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>

View File

@@ -18,6 +18,7 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
WatchApp/Sources/OpenClawWatchApp.swift
WatchApp/Sources/WatchConnectivityReceiver.swift
WatchApp/Sources/WatchInboxStore.swift
WatchApp/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

View File

@@ -8,7 +8,7 @@ import Testing
gatewayConnected: false,
discoveredGatewayCount: 0,
talkConfigLoaded: false,
notificationStatusText: "Not Set") == [
notificationsAllowed: false) == [
.gatewayOffline,
.discoveryUnavailable,
.notificationsUnavailable,
@@ -21,12 +21,12 @@ import Testing
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: false,
notificationStatusText: "Allowed") == [.talkConfigMissing])
notificationsAllowed: true) == [.talkConfigMissing])
#expect(
SettingsDiagnostics.issueCount(
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: true,
notificationStatusText: "Allowed") == 0)
notificationsAllowed: true) == 0)
}
}

View File

@@ -3,6 +3,10 @@ import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
suggestions: [String],
onSubmit: @escaping (String) -> Void)
{
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
withSuggestions: suggestions,
allowedInputMode: .allowEmoji)
{ results in

View File

@@ -1,6 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>
</dict>
</dict>
</plist>

View File

@@ -293,6 +293,8 @@ def capture_watch_screenshot
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
@@ -303,7 +305,7 @@ def capture_watch_screenshot
"-configuration",
"Debug",
"-destination",
"platform=watchOS Simulator,id=#{udid}",
"generic/platform=watchOS Simulator",
"-derivedDataPath",
derived_data_path,
"build",
@@ -311,10 +313,8 @@ def capture_watch_screenshot
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
screenshot_mode_bundle_identifiers = [watch_app_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
@@ -492,6 +492,9 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
services
end
@@ -567,6 +570,15 @@ def profile_plist_value(profile_path, key_path)
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
@@ -582,6 +594,17 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -65,6 +65,8 @@ targets:
embed: true
- target: OpenClawActivityWidget
embed: true
# A companion watch application belongs in the standard Watch bundle location.
# PlugIns is for extension products and breaks paired watch installation.
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -88,7 +90,7 @@ targets:
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
@@ -140,6 +142,7 @@ targets:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -192,6 +195,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -206,6 +210,7 @@ targets:
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -251,13 +256,17 @@ targets:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
OpenClawWatchApp:
type: application.watchapp2
type: application
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchApp
excludes:
- Info.plist
dependencies:
- target: OpenClawWatchExtension
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
@@ -274,6 +283,8 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
info:
path: WatchApp/Info.plist
properties:
@@ -281,42 +292,7 @@ targets:
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
OpenClawWatchExtension:
type: watchkit2-extension
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
WKApplication: true
OpenClawTests:
type: bundle.unit-test

View File

@@ -0,0 +1,32 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in canvas HTML to a native `NSOpenPanel`.
/// Without a `WKUIDelegate`, WebKit silently drops the request and file-picker
/// buttons in canvas pages do nothing.
@MainActor
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
}

View File

@@ -5,7 +5,7 @@ import OpenClawKit
import WebKit
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
let sessionKey: String
private let root: URL
private let sessionDir: URL
@@ -159,6 +159,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()

View File

@@ -19,7 +19,7 @@ private final class DashboardWindowDragRegionView: NSView {
}
@MainActor
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
private let webView: WKWebView
private var currentURL: URL
private var auth: DashboardWindowAuth
@@ -44,9 +44,37 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
super.init(window: window)
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
}
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in the embedded Control UI to a native
/// `NSOpenPanel`; without a `WKUIDelegate`, WebKit silently drops the request
/// and "Choose image" / file-picker buttons do nothing.
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")

View File

@@ -21,10 +21,12 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -33,10 +35,11 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = []) -> DeviceAuthEntry
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore()
var next = self.readStore(profile: profile)
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -50,17 +53,25 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store)
self.writeStore(store, profile: profile)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
}
private static func normalizeRole(_ role: String) -> String {
@@ -74,14 +85,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.authFileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -90,8 +101,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,6 +1,29 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -19,6 +42,32 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -27,34 +76,49 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL())
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case .identity(let decoded):
case let .identity(decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -143,7 +207,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32 && privateKeyData.count == 32,
guard publicKeyData.count == 32, privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -211,11 +275,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
}
}

View File

@@ -107,6 +107,7 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -122,6 +123,7 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -133,6 +135,7 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -436,13 +439,15 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -532,7 +537,11 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -550,7 +559,10 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
}
throw error
}
@@ -559,6 +571,7 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -568,7 +581,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -756,7 +769,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -765,7 +779,8 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes)
scopes: filteredScopes,
profile: deviceIdentityProfile)
}
private func persistIssuedDeviceToken(
@@ -773,7 +788,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -783,20 +799,23 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
profile: deviceIdentityProfile)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String) async throws
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
{
if res.ok == false {
let error = res.error
@@ -855,7 +874,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -873,7 +893,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
}
}

View File

@@ -162,6 +162,7 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -179,6 +180,7 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -0,0 +1,11 @@
import Foundation
public enum OpenClawAppGroup {
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
public static var identifier: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
}
}

View File

@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
}
public enum ShareGatewayRelaySettings {
private static let suiteName = "group.ai.openclaw.shared"
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let relayConfigKey = "share.gatewayRelay.config.v1"
private static let lastEventKey = "share.gatewayRelay.event.v1"

View File

@@ -1,7 +1,7 @@
import Foundation
public enum ShareToAgentSettings {
private static let suiteName = "group.ai.openclaw.shared"
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let defaultInstructionKey = "share.defaultInstruction"
private static var defaults: UserDefaults {

View File

@@ -548,6 +548,7 @@ public struct MessageActionParams: Codable, Sendable {
public let action: String
public let params: [String: AnyCodable]
public let accountid: String?
public let requesteraccountid: String?
public let requestersenderid: String?
public let senderisowner: Bool?
public let sessionkey: String?
@@ -562,6 +563,7 @@ public struct MessageActionParams: Codable, Sendable {
action: String,
params: [String: AnyCodable],
accountid: String?,
requesteraccountid: String? = nil,
requestersenderid: String?,
senderisowner: Bool?,
sessionkey: String?,
@@ -575,6 +577,7 @@ public struct MessageActionParams: Codable, Sendable {
self.action = action
self.params = params
self.accountid = accountid
self.requesteraccountid = requesteraccountid
self.requestersenderid = requestersenderid
self.senderisowner = senderisowner
self.sessionkey = sessionkey
@@ -590,6 +593,7 @@ public struct MessageActionParams: Codable, Sendable {
case action
case params
case accountid = "accountId"
case requesteraccountid = "requesterAccountId"
case requestersenderid = "requesterSenderId"
case senderisowner = "senderIsOwner"
case sessionkey = "sessionKey"

View File

@@ -5,8 +5,99 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `share extension profile uses separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(
DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
}
@Test
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -40,8 +131,8 @@ struct DeviceIdentityStoreTests {
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
}
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
@Test
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -52,14 +143,14 @@ struct DeviceIdentityStoreTests {
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)

View File

@@ -0,0 +1,22 @@
import OpenClawProtocol
import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
params: [:],
accountid: "default",
requestersenderid: "U123",
senderisowner: true,
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
#expect(params.requesteraccountid == nil)
}
}

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawKit
import OpenClawProtocol
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
@@ -18,7 +18,9 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
self.callbacks = callbacks
}
var state: URLSessionTask.State { .running }
var state: URLSessionTask.State {
.running
}
func resume() {}
@@ -53,6 +55,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@@ -73,7 +76,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
@@ -92,10 +98,13 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
self.connectDevice = device
}
}
}
@@ -104,6 +113,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectAuth }
}
func latestConnectDevice() -> [String: Any]? {
self.lock.withLock { self.connectDevice }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -134,7 +147,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
func emitReceiveFailure() {
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
self._state = .canceling
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
@@ -175,7 +191,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30_000,
"tickIntervalMs": 30000,
],
"auth": [:],
]
@@ -223,20 +239,25 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
private actor SeqGapProbe {
private var saw = false
func mark() { self.saw = true }
func value() -> Bool { self.saw }
func mark() {
self.saw = true
}
func value() -> Bool {
self.saw
}
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
func `websocket ping ignores duplicate success callbacks`() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
func `websocket ping ignores duplicate callbacks after first error`() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
@@ -249,7 +270,7 @@ struct GatewayNodeSessionTests {
}
@Test
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -284,7 +305,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -305,7 +326,74 @@ struct GatewayNodeSessionTests {
}
@Test
func passwordTakesPrecedenceOverBootstrapToken() async throws {
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-node-token")
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "share-node-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
let shareDeviceId = try #require(shareDevice["id"] as? String)
#expect(shareDeviceId != primaryIdentity.deviceId)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
.token == "primary-node-token")
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
#expect(
DeviceAuthStore
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
"share-node-token")
await gateway.disconnect()
}
@Test
func `password takes precedence over bootstrap token`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -320,7 +408,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
@@ -341,7 +429,7 @@ struct GatewayNodeSessionTests {
}
@Test
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
func `changed session box rebuilds existing gateway channel`() async throws {
let firstSession = FakeGatewayWebSocketSession()
let secondSession = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
@@ -357,7 +445,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -370,7 +458,7 @@ struct GatewayNodeSessionTests {
})
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -389,7 +477,7 @@ struct GatewayNodeSessionTests {
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
func `bootstrap hello stores additional device tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -440,7 +528,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -468,7 +556,7 @@ struct GatewayNodeSessionTests {
}
@Test
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -509,7 +597,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -530,7 +618,7 @@ struct GatewayNodeSessionTests {
}
@Test
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -574,7 +662,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -593,25 +681,25 @@ struct GatewayNodeSessionTests {
}
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com")!)
activeURL: #require(URL(string: "wss://gateway.example.com")))
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com:7443")!)
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
func `invoke with timeout returns underlying response before timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -619,8 +707,7 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
@@ -628,7 +715,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
func `invoke with timeout returns timeout error`() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -636,8 +723,7 @@ struct GatewayNodeSessionTests {
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
@@ -645,7 +731,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
func `invoke with timeout zero disables timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -653,15 +739,14 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
}
@Test
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
func `emits synthetic seq gap after reconnect snapshot`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -687,7 +772,7 @@ struct GatewayNodeSessionTests {
}
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,

View File

@@ -1,4 +1,4 @@
e78623d6eace69e46950cd5d9a5cf14aa910dac1ecdf9d054a0bd9999e936061 config-baseline.json
5ecafa3c9a59fc0675f964f6e3238b2f20625376ebad1835278c5dd7323770d3 config-baseline.core.json
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
7c2c51b795d32e4c4c325080d59fec8fd11317c41db7db642f70e436779738bc config-baseline.plugin.json
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl

View File

@@ -1194,5 +1194,9 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]

View File

@@ -52,6 +52,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
## Notes

View File

@@ -1409,10 +1409,14 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- global shortcuts: callback and actor metadata, routed to the actor's direct session
- message shortcuts: callback, actor, channel, thread, and selected-message context
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
## Configuration reference
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).

View File

@@ -0,0 +1,95 @@
---
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
read_when:
- You want a personal Zalo assistant bot with QR-code login
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
title: "Zalo ClawBot"
---
OpenClaw connects to Zalo ClawBot through the catalog-listed external
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
code.
## Compatibility
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
| -------------- | ---------------- | ------------ | ------------- |
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
## Prerequisites
- Node.js **>= 22**
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
- A Zalo account on a mobile device to scan the login QR code.
## Install with onboard (recommended)
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
```bash
openclaw onboard
```
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
## Manual Installation
To add the channel to an already-onboarded gateway, follow these steps:
### 1. Install the plugin
```bash
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
```
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
### 2. Enable the plugin in config
```bash
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
```
### 3. Generate QR code and log in
```bash
openclaw channels login --channel openclaw-zaloclawbot
```
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
---
## How It Works
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
browser or web-session automation.
## Under the Hood
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
- Long-poll connections utilize the `getUpdates` endpoint.
- Webhooks are disabled by default for local desktop/terminal gateway runs.
- Messages are processed client-side and mapped directly to your local agent runtime.
The external plugin manages bot credentials under the OpenClaw state directory.
Treat that directory as sensitive and include it in the same access-control and
backup policy as the rest of your OpenClaw state.
---
## Troubleshooting
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.

View File

@@ -315,7 +315,7 @@ Current existing-session limits:
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
per-call timeout overrides
- `select` supports one value only
- `wait --load networkidle` is not supported
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
- file uploads require `--ref` / `--input-ref`, do not support CSS
`--element`, and currently support one file at a time
- dialog hooks do not support `--timeout`

View File

@@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`.
### Using a different model
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string:
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`:
```json
{
@@ -76,6 +76,8 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp
}
```
Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider.
This works with local models too, for example a second Ollama model dedicated to summarization:
```json

View File

@@ -315,9 +315,15 @@ The same section also includes the OpenClaw source location. Git checkouts expos
source root so the agent can inspect code directly. Package installs include the GitHub
source URL and tell the agent to review source there whenever the docs are incomplete or
stale. The prompt also notes the public docs mirror, community Discord, and ClawHub
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It tells the model to
consult docs first for OpenClaw behavior, commands, configuration, or architecture, and to
run `openclaw status` itself when possible (asking the user only when it lacks access).
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It frames docs as the
authority for OpenClaw self-knowledge before the model understands how OpenClaw works,
including memory/daily notes, sessions, tools, Gateway, config, commands, or project
context. The prompt tells the model to use local docs (or the docs mirror when local docs
are unavailable) first, and to treat AGENTS.md, project context, workspace/profile/memory
notes, and `memory_search` as instruction context or user memory rather than OpenClaw
design or implementation knowledge. If docs are silent or stale, the model should say so
and inspect source. The prompt also tells the model to run `openclaw status` itself when
possible, asking the user only when it lacks access.
For configuration specifically, it points agents to the `gateway` tool action
`config.schema.lookup` for exact field-level docs and constraints, then to
`docs/gateway/configuration.md` and `docs/gateway/configuration-reference.md`

View File

@@ -316,6 +316,10 @@
"source": "/providers/zalo",
"destination": "/channels/zalo"
},
{
"source": "/channels/openclaw-zaloclawbot",
"destination": "/channels/zaloclawbot"
},
{
"source": "/providers/whatsapp",
"destination": "/channels/whatsapp"
@@ -1132,6 +1136,7 @@
"channels/feishu",
"channels/yuanbao",
"channels/zalo",
"channels/zaloclawbot",
"channels/zalouser"
]
},

View File

@@ -668,7 +668,7 @@ Periodic heartbeat runs.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.

View File

@@ -1096,6 +1096,7 @@ Notes:
traces: true,
metrics: true,
logs: false,
logsExporter: "otlp",
sampleRate: 1.0,
flushIntervalMs: 5000,
captureContent: {
@@ -1132,6 +1133,7 @@ Notes:
- `otel.headers`: extra HTTP/gRPC metadata headers sent with OTel export requests.
- `otel.serviceName`: service name for resource attributes.
- `otel.traces` / `otel.metrics` / `otel.logs`: enable trace, metrics, or log export.
- `otel.logsExporter`: log export sink: `"otlp"` (default), `"stdout"` for one JSON object per stdout line, or `"both"`.
- `otel.sampleRate`: trace sampling rate `0`-`1`.
- `otel.flushIntervalMs`: periodic telemetry flush interval in ms.
- `otel.captureContent`: opt-in raw content capture for OTEL span attributes. Defaults to off. Boolean `true` captures non-system message/tool content; the object form lets you enable `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, `systemPrompt`, and `toolDefinitions` explicitly.

View File

@@ -1,5 +1,5 @@
---
summary: "Export OpenClaw diagnostics to any OpenTelemetry collector via the diagnostics-otel plugin (OTLP/HTTP)"
summary: "Export OpenClaw diagnostics to OpenTelemetry collectors or stdout JSONL via the diagnostics-otel plugin"
title: "OpenTelemetry export"
read_when:
- You want to send OpenClaw model usage, message flow, or session metrics to an OpenTelemetry collector
@@ -8,9 +8,10 @@ read_when:
---
OpenClaw exports diagnostics through the official `diagnostics-otel` plugin
using **OTLP/HTTP (protobuf)**. Any collector or backend that accepts OTLP/HTTP
works without code changes. For local file logs and how to read them, see
[Logging](/logging).
using **OTLP/HTTP (protobuf)**. Logs can also be written as stdout JSONL for
container and sandbox log pipelines. Any collector or backend that accepts
OTLP/HTTP works without code changes. For local file logs and how to read them,
see [Logging](/logging).
## How it fits together
@@ -18,7 +19,8 @@ works without code changes. For local file logs and how to read them, see
Gateway and bundled plugins for model runs, message flow, sessions, queues,
and exec.
- **`diagnostics-otel` plugin** subscribes to those events and exports them as
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP.
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP. It can
also mirror diagnostic log records to stdout JSONL.
- **Provider calls** receive a W3C `traceparent` header from OpenClaw's
trusted model-call span context when the provider transport accepts custom
headers. Plugin-emitted trace context is not propagated.
@@ -74,11 +76,13 @@ openclaw plugins enable diagnostics-otel
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Metrics** | Counters and histograms for token usage, cost, run duration, failover, skill usage, message flow, Talk events, queue lanes, session state/recovery, tool execution, oversized payloads, exec, and memory pressure. |
| **Traces** | Spans for model usage, model calls, harness lifecycle, skill usage, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
| **Logs** | Structured `logging.file` records exported over OTLP or stdout JSONL when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
Toggle `traces`, `metrics`, and `logs` independently. Traces and metrics
default to on when `diagnostics.otel.enabled` is true. Logs default to off and
are exported only when `diagnostics.otel.logs` is explicitly `true`.
are exported only when `diagnostics.otel.logs` is explicitly `true`. Log export
defaults to OTLP; set `diagnostics.otel.logsExporter` to `stdout` for JSONL on
stdout, or `both` to send each diagnostic log record to OTLP and stdout.
## Configuration reference
@@ -98,6 +102,7 @@ are exported only when `diagnostics.otel.logs` is explicitly `true`.
traces: true,
metrics: true,
logs: true,
logsExporter: "otlp", // otlp | stdout | both
sampleRate: 0.2, // root-span sampler, 0.0..1.0
flushIntervalMs: 60000, // metric export interval (min 1000ms)
captureContent: {
@@ -176,6 +181,11 @@ on the public diagnostic event bus.
- **Logs:** OTLP logs respect `logging.level` (file log level). They use the
diagnostic log-record redaction path, not console formatting. High-volume
installs should prefer OTLP collector sampling/filtering over local sampling.
Set `diagnostics.otel.logsExporter: "stdout"` when your platform already
ships stdout/stderr to a log processor and you do not have an OTLP logs
collector. Stdout records are one JSON object per line with `ts`, `signal`,
`service.name`, severity, body, redacted attributes, and trusted trace fields
when available.
- **File-log correlation:** JSONL file logs include top-level `traceId`,
`spanId`, `parentSpanId`, and `traceFlags` when the log call carries a valid
diagnostic trace context, which lets log processors join local log lines with

View File

@@ -224,8 +224,10 @@ model-call traces become children of the active request trace, so local logs,
diagnostic snapshots, OTEL spans, and trusted provider `traceparent` headers can
be joined by `traceId` without logging raw request or model content.
Talk lifecycle log records also flow to OTLP logs when OpenTelemetry log export
is enabled, using the same bounded attributes as file logs.
Talk lifecycle log records also flow to diagnostics-otel log export when
OpenTelemetry log export is enabled, using the same bounded attributes as file
logs. Configure `diagnostics.otel.logsExporter` to choose OTLP, stdout JSONL, or
both sinks.
### Model call size and timing

View File

@@ -91,8 +91,8 @@ Supported `appServer` fields:
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
@@ -149,11 +149,15 @@ must report stable version `0.125.0` or newer.
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
identity-bearing WebSocket auth through `appServer.authToken` or an
`Authorization` header. When native Codex plugins are configured, OpenClaw uses
the connected app-server's plugin control plane to install or refresh those
plugins and then refreshes app inventory so plugin-owned apps are visible to the
Codex thread. Only connect OpenClaw to remote app-servers that are trusted to
accept OpenClaw-managed plugin installs and app inventory refreshes.
`Authorization` header. `appServer.authToken` and each `appServer.headers.*`
value can be a SecretInput; the secrets runtime resolves SecretRefs and env
shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -552,8 +552,8 @@ Supported `appServer` fields:
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |

View File

@@ -227,7 +227,7 @@ Each entry lists the package, distribution route, and description.
- **[deepseek](/plugins/reference/deepseek)** (`@openclaw/deepseek-provider`) - npm; ClawHub: `clawhub:@openclaw/deepseek-provider`. Adds DeepSeek model provider support to OpenClaw.
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
- **[diagnostics-prometheus](/plugins/reference/diagnostics-prometheus)** (`@openclaw/diagnostics-prometheus`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus`. OpenClaw diagnostics Prometheus exporter for runtime metrics.

View File

@@ -1,5 +1,5 @@
---
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics and traces."
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs."
read_when:
- You are installing, configuring, or auditing the diagnostics-otel plugin
title: "Diagnostics OpenTelemetry plugin"
@@ -7,7 +7,7 @@ title: "Diagnostics OpenTelemetry plugin"
# Diagnostics OpenTelemetry plugin
OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
## Distribution

View File

@@ -164,7 +164,9 @@ two-party event loops that do not go through the shared inbound reply runner.
});
```
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
</Accordion>
<Accordion title="api.runtime.agent.defaults">

View File

@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

View File

@@ -504,9 +504,10 @@ Legacy aliases still normalize to the canonical bundled ids:
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.

View File

@@ -43,6 +43,8 @@ Scope intent:
- `tools.web.fetch.firecrawl.apiKey`
- `plugins.entries.acpx.config.mcpServers.*.env.*`
- `plugins.entries.brave.config.webSearch.apiKey`
- `plugins.entries.codex.config.appServer.authToken`
- `plugins.entries.codex.config.appServer.headers.*`
- `plugins.entries.exa.config.webSearch.apiKey`
- `plugins.entries.google-meet.config.realtime.providers.*.apiKey`
- `plugins.entries.google.config.webSearch.apiKey`

View File

@@ -554,6 +554,20 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.codex.config.appServer.authToken",
"configFile": "openclaw.json",
"path": "plugins.entries.codex.config.appServer.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.codex.config.appServer.headers.*",
"configFile": "openclaw.json",
"path": "plugins.entries.codex.config.appServer.headers.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.exa.config.webSearch.apiKey",
"configFile": "openclaw.json",

View File

@@ -322,6 +322,7 @@ You can wait on more than just time/text:
- `openclaw browser wait --url "**/dash"`
- Wait for load state:
- `openclaw browser wait --load networkidle`
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
- Wait for a JS predicate:
- `openclaw browser wait --fn "window.ready===true"`
- Wait for a selector to become visible:

View File

@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.

View File

@@ -173,6 +173,7 @@ plugins.
| --- | --- |
| `/new [model]` | Archive the current session and start a fresh one |
| `/reset [soft [message]]` | Reset the current session in place. `soft` keeps the transcript, drops reused CLI backend session ids, and reruns startup |
| `/name <title>` | Name or rename the current session. Omit the title to see the current name and a suggestion |
| `/compact [instructions]` | Compact the session context. See [Compaction](/concepts/compaction) |
| `/stop` | Abort the current run |
| `/session idle <duration\|off>` | Manage thread-binding idle expiry |

View File

@@ -297,8 +297,3 @@ export function renderIsolatedCodexConfig(params: {
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
.join("\n");
}
/** Render only the project trust section for a session-local Codex config. */
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
return renderIsolatedCodexConfig({ projectPaths });
}

View File

@@ -1,11 +1,11 @@
// Bonjour tests cover ciao plugin behavior.
import { describe, expect, it } from "vitest";
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");
const { classifyCiaoProcessError } = await import("./ciao.js");
describe("bonjour-ciao", () => {
it("classifies ciao cancellation rejections separately from side effects", () => {
expect(classifyCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toEqual({
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).toEqual({
kind: "cancellation",
formatted: "CIAO PROBING CANCELLED",
});
@@ -13,7 +13,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao interface assertions separately from side effects", () => {
expect(
classifyCiaoUnhandledRejection(
classifyCiaoProcessError(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
),
).toEqual({
@@ -24,7 +24,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao interface assertions using changed wording", () => {
expect(
classifyCiaoUnhandledRejection(
classifyCiaoProcessError(
new Error("Reached illegal state! IPv4 address changed from undefined to defined!"),
),
).toEqual({
@@ -35,7 +35,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao netmask assertions separately from side effects", () => {
expect(
classifyCiaoUnhandledRejection(
classifyCiaoProcessError(
Object.assign(
new Error(
"IP address version must match. Netmask cannot have a version different from the address!",
@@ -52,7 +52,7 @@ describe("bonjour-ciao", () => {
it("classifies ciao self-probe races separately from side effects", () => {
expect(
classifyCiaoUnhandledRejection(
classifyCiaoProcessError(
new Error(
"Can't probe for a service which is announced already. Received announcing for service OpenClaw Gateway._openclaw._tcp.local.",
),
@@ -65,18 +65,18 @@ describe("bonjour-ciao", () => {
});
it("suppresses ciao announcement cancellation rejections", () => {
expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe(
true,
expect(classifyCiaoProcessError(new Error("Ciao announcement cancelled by shutdown"))).not.toBe(
null,
);
});
it("suppresses ciao probing cancellation rejections", () => {
expect(ignoreCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toBe(true);
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).not.toBe(null);
});
it("suppresses wrapped ciao cancellation rejections", () => {
expect(
classifyCiaoUnhandledRejection({
classifyCiaoProcessError({
reason: new Error("CIAO ANNOUNCEMENT CANCELLED"),
}),
).toEqual({
@@ -87,7 +87,7 @@ describe("bonjour-ciao", () => {
it("suppresses aggregate ciao assertion rejections", () => {
expect(
classifyCiaoUnhandledRejection(
classifyCiaoProcessError(
new AggregateError([
Object.assign(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
@@ -103,7 +103,7 @@ describe("bonjour-ciao", () => {
});
it("suppresses lower-case string cancellation reasons too", () => {
expect(ignoreCiaoUnhandledRejection("ciao announcement cancelled during cleanup")).toBe(true);
expect(classifyCiaoProcessError("ciao announcement cancelled during cleanup")).not.toBe(null);
});
it("suppresses ciao interface assertion rejections as non-fatal", () => {
@@ -112,7 +112,7 @@ describe("bonjour-ciao", () => {
{ name: "AssertionError" },
);
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
expect(classifyCiaoProcessError(error)).not.toBe(null);
});
it("suppresses ciao netmask assertion errors as non-fatal", () => {
@@ -123,7 +123,7 @@ describe("bonjour-ciao", () => {
{ name: "AssertionError" },
);
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
expect(classifyCiaoProcessError(error)).not.toBe(null);
});
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
@@ -131,7 +131,7 @@ describe("bonjour-ciao", () => {
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
expect(classifyCiaoUnhandledRejection(err)).toEqual({
expect(classifyCiaoProcessError(err)).toEqual({
kind: "interface-enumeration-failure",
formatted:
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
@@ -144,10 +144,10 @@ describe("bonjour-ciao", () => {
{ name: "SystemError" },
);
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
expect(ignoreCiaoUnhandledRejection(wrapper)).toBe(true);
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
});
it("keeps unrelated rejections visible", () => {
expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false);
expect(classifyCiaoProcessError(new Error("boom"))).toBe(null);
});
});

View File

@@ -55,15 +55,3 @@ export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClass
}
return null;
}
/**
* Backward-compatible alias for unhandled-rejection classification.
*
* @deprecated Use classifyCiaoProcessError.
*/
export const classifyCiaoUnhandledRejection = classifyCiaoProcessError;
/** Return whether a ciao unhandled rejection is known and ignorable. */
export function ignoreCiaoUnhandledRejection(reason: unknown): boolean {
return classifyCiaoProcessError(reason) !== null;
}

View File

@@ -7,7 +7,6 @@ import {
type AriaSnapshotNode,
captureScreenshot,
createTargetViaCdp,
evaluateJavaScript,
formatAriaSnapshot,
normalizeCdpWsUrl,
type RawAXNode,
@@ -329,47 +328,6 @@ describe("cdp internal", () => {
});
});
describe("evaluateJavaScript", () => {
it("throws when Runtime.evaluate returns no result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
/Runtime\.evaluate returned no result/,
);
});
it("surfaces CDP exceptionDetails alongside result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: { type: "undefined" },
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
},
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
expect(res.exceptionDetails?.text).toBe("ReferenceError");
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
@@ -939,27 +897,6 @@ describe("cdp internal", () => {
expect(snap.nodes).toStrictEqual([]);
});
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 1 } },
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
expect(res.result.value).toBe(1);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
@@ -1008,5 +945,4 @@ describe("cdp internal", () => {
expect(buf.toString("utf8")).toBe("S");
});
});
});

View File

@@ -12,7 +12,7 @@ import {
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
} from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
@@ -412,32 +412,6 @@ describe("cdp", () => {
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
expect(msg.params?.expression).toBe("1+1");
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 2 } },
}),
);
}
});
const res = await evaluateJavaScript({
wsUrl: `ws://127.0.0.1:${wsPort}`,
expression: "1+1",
});
expect(res.result.type).toBe("number");
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(

View File

@@ -299,56 +299,6 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Runtime.evaluate remote-object subset used by CDP helpers. */
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
/** Exception details surfaced from CDP Runtime.evaluate. */
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
/** Evaluate JavaScript in a CDP target and return by value when possible. */
export async function evaluateJavaScript(opts: {
wsUrl: string;
expression: string;
awaitPromise?: boolean;
returnByValue?: boolean;
}): Promise<{
result: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Runtime.enable").catch(() => {});
const evaluated = (await send("Runtime.evaluate", {
expression: opts.expression,
awaitPromise: Boolean(opts.awaitPromise),
returnByValue: opts.returnByValue ?? true,
userGesture: true,
includeCommandLineAPI: true,
})) as {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
};
const result = evaluated?.result;
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clickChromeMcpCoords,
clickChromeMcpElement,
buildChromeMcpArgs,
decodeChromeMcpStderrTail,
ensureChromeMcpAvailable,
evaluateChromeMcpScript,
@@ -212,114 +211,6 @@ describe("chrome MCP page parsing", () => {
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--userDataDir",
"/tmp/brave-profile",
]);
});
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "http://127.0.0.1:9222",
userDataDir: "/tmp/brave-profile",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--wsEndpoint",
"ws://127.0.0.1:9222/devtools/browser/abc",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
expect(
buildChromeMcpArgs({
userDataDir: "/tmp/brave-profile",
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
]);
});
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--usage-statistics",
]);
});
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--no-usage-statistics",
]);
});
it("omits the npx package prefix for a custom Chrome MCP command", () => {
expect(
buildChromeMcpArgs({
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
cdpUrl: "http://127.0.0.1:9222",
}),
).toEqual([
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
const session = createFakeSession();
Object.assign(session, { ownsProcessTree: true });

View File

@@ -462,11 +462,6 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
];
}
/** Build command-line args for launching chrome-devtools-mcp. */
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
}
function drainStderr(transport: StdioClientTransport): () => string {
const stream = transport.stderr;
if (!stream) {

View File

@@ -36,12 +36,133 @@ function createElementProgram(): Command {
return program;
}
function getLastActionBody(): Record<string, unknown> | undefined {
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
?.body;
}
describe("browser element commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it.each([
{
name: "click",
argv: [
"browser",
"click",
" ref-1 ",
"--target-id",
"tab-1",
"--double",
"--button",
"right",
"--modifiers",
"Shift, Alt",
],
expectedBody: {
kind: "click",
ref: "ref-1",
targetId: "tab-1",
doubleClick: true,
button: "right",
modifiers: ["Shift", "Alt"],
},
},
{
name: "click-coords",
argv: [
"browser",
"click-coords",
"12.5",
"42",
"--target-id",
"tab-2",
"--double",
"--button",
"middle",
"--delay-ms",
"25",
],
expectedBody: {
kind: "clickCoords",
x: 12.5,
y: 42,
targetId: "tab-2",
doubleClick: true,
button: "middle",
delayMs: 25,
},
},
{
name: "type",
argv: ["browser", "type", "input-1", "hello", "--submit", "--slowly", "--target-id", "tab-2"],
expectedBody: {
kind: "type",
ref: "input-1",
text: "hello",
submit: true,
slowly: true,
targetId: "tab-2",
},
},
{
name: "press",
argv: ["browser", "press", "Enter", "--target-id", "tab-3"],
expectedBody: { kind: "press", key: "Enter", targetId: "tab-3" },
},
{
name: "hover",
argv: ["browser", "hover", "node-1", "--target-id", "tab-4"],
expectedBody: { kind: "hover", ref: "node-1", targetId: "tab-4" },
},
{
name: "scrollintoview",
argv: ["browser", "scrollintoview", "node-2", "--target-id", "tab-5"],
expectedBody: { kind: "scrollIntoView", ref: "node-2", targetId: "tab-5" },
},
{
name: "drag",
argv: ["browser", "drag", "start-1", "end-1", "--target-id", "tab-6"],
expectedBody: {
kind: "drag",
startRef: "start-1",
endRef: "end-1",
targetId: "tab-6",
},
},
{
name: "select",
argv: ["browser", "select", "select-1", "alpha", "beta", "--target-id", "tab-7"],
expectedBody: {
kind: "select",
ref: "select-1",
values: ["alpha", "beta"],
targetId: "tab-7",
},
},
])("sends the expected $name action body", async ({ argv, expectedBody }) => {
const program = createElementProgram();
await program.parseAsync(argv, { from: "user" });
expect(getLastActionBody()).toMatchObject(expectedBody);
});
it("rejects a blank required ref before dispatch", async () => {
const program = createElementProgram();
await expect(program.parseAsync(["browser", "click", " "], { from: "user" })).rejects.toThrow(
"__exit__:1",
);
const capture = getBrowserCliRuntimeCapture();
expect(capture.runtimeErrors.join("\n")).toContain("ref is required");
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
});
it("rejects non-decimal coordinate values before dispatch", async () => {
const program = createElementProgram();

View File

@@ -31,6 +31,10 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
);
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
vi.spyOn(cliCoreApiModule, "resolveExistingUploadPaths").mockResolvedValue({
ok: true,
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
});
const { registerBrowserActionInputCommands } = await import("./register.js");
@@ -47,10 +51,51 @@ function getLastRequestOptions(): { timeoutMs?: number } | undefined {
describe("browser action input file/download commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
vi.mocked(cliCoreApiModule.resolveExistingUploadPaths).mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
getBrowserCliRuntime().exit.mockImplementation(() => {});
});
it("arms uploads with normalized paths and element targeting options", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"upload",
"/tmp/openclaw/uploads/a.pdf",
"media://inbound/b",
"--input-ref",
"file-input",
"--element",
"input[type=file]",
"--target-id",
"tab-1",
"--timeout-ms",
"45000",
],
{ from: "user" },
);
expect(cliCoreApiModule.resolveExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/a.pdf", "media://inbound/b"],
});
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { path?: string; body?: Record<string, unknown> }
| undefined;
expect(request).toMatchObject({
path: "/hooks/file-chooser",
body: {
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
inputRef: "file-input",
element: "input[type=file]",
targetId: "tab-1",
timeoutMs: 45000,
},
});
expect(getLastRequestOptions()?.timeoutMs).toBeGreaterThan(45000);
});
it("keeps the outer waitfordownload request open for the advertised default wait", async () => {
const program = createActionInputProgram();

View File

@@ -36,6 +36,43 @@ function createActionInputProgram(): Command {
return program;
}
function getLastActionBody(): Record<string, unknown> | undefined {
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
?.body;
}
describe("browser action input fill command", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends normalized fill fields and target id to the act route", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"fill",
"--fields",
'[{"ref":"name","value":"Ada"},{"ref":"enabled","value":true}]',
"--target-id",
"tab-1",
],
{ from: "user" },
);
expect(getLastActionBody()).toMatchObject({
kind: "fill",
fields: [
{ ref: "name", type: "text", value: "Ada" },
{ ref: "enabled", type: "text", value: true },
],
targetId: "tab-1",
});
});
});
describe("browser action input wait command", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
@@ -99,6 +136,31 @@ describe("browser action input evaluate command", () => {
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends evaluate function, ref, and target id to the act route", async () => {
const program = createActionInputProgram();
await program.parseAsync(
[
"browser",
"evaluate",
"--fn",
"el => el.textContent",
"--ref",
"button-1",
"--target-id",
"tab-2",
],
{ from: "user" },
);
expect(getLastActionBody()).toMatchObject({
kind: "evaluate",
fn: "el => el.textContent",
ref: "button-1",
targetId: "tab-2",
});
});
it("passes timeout-ms through to the evaluate action and outer request", async () => {
const program = createActionInputProgram();

View File

@@ -2,6 +2,7 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserCliResizeModule from "../browser-cli-resize.js";
import * as browserCliSharedModule from "../browser-cli-shared.js";
import {
createBrowserProgram,
getBrowserCliRuntime,
@@ -10,9 +11,17 @@ import {
import * as cliCoreApiModule from "../core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
opts?: unknown,
req?: unknown,
extra?: { timeoutMs?: number },
) => Promise<Record<string, unknown>>
>(async () => ({ url: "https://example.test/landing" })),
runBrowserResizeWithOutput: vi.fn(async () => {}),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
vi.spyOn(browserCliResizeModule, "runBrowserResizeWithOutput").mockImplementation(
mocks.runBrowserResizeWithOutput,
);
@@ -34,10 +43,51 @@ function createNavigationProgram(): Command {
describe("browser navigation commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
mocks.runBrowserResizeWithOutput.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("sends navigate requests with the URL and target id", async () => {
const program = createNavigationProgram();
await program.parseAsync(
["browser", "navigate", "https://example.test/page", "--target-id", "tab-1"],
{ from: "user" },
);
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { method?: string; path?: string; body?: Record<string, unknown> }
| undefined;
const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
| { timeoutMs?: number }
| undefined;
expect(request).toMatchObject({
method: "POST",
path: "/navigate",
body: { url: "https://example.test/page", targetId: "tab-1" },
});
expect(options?.timeoutMs).toBe(20000);
});
it("passes normalized resize dimensions and target id to the resize helper", async () => {
const program = createNavigationProgram();
await program.parseAsync(["browser", "resize", "1024", "768", "--target-id", "tab-2"], {
from: "user",
});
expect(mocks.runBrowserResizeWithOutput).toHaveBeenCalledWith(
expect.objectContaining({
width: 1024,
height: 768,
targetId: "tab-2",
timeoutMs: 20000,
successMessage: "resized to 1024x768",
}),
);
});
it("rejects non-decimal resize dimensions before dispatch", async () => {
const program = createNavigationProgram();

View File

@@ -6,7 +6,7 @@ import {
isLiveTestEnabled,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { BYTEPLUS_CODING_BASE_URL, BYTEPLUS_DEFAULT_COST } from "./models.js";
import { BYTEPLUS_CODING_BASE_URL } from "./models.js";
const BYTEPLUS_KEY = process.env.BYTEPLUS_API_KEY ?? "";
const BYTEPLUS_CODING_MODEL = process.env.BYTEPLUS_CODING_MODEL?.trim() || "ark-code-latest";
@@ -33,7 +33,7 @@ describeLive("byteplus coding plan live", () => {
baseUrl: BYTEPLUS_CODING_BASE_URL,
reasoning: false,
input: ["text"],
cost: BYTEPLUS_DEFAULT_COST,
cost: { input: 0.0001, output: 0.0002, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 4096,
};

View File

@@ -20,14 +20,6 @@ export const BYTEPLUS_BASE_URL = BYTEPLUS_MANIFEST_PROVIDER.baseUrl;
/** Base URL for BytePlus Plan coding APIs from the manifest catalog. */
export const BYTEPLUS_CODING_BASE_URL = BYTEPLUS_CODING_MANIFEST_PROVIDER.baseUrl;
/** Fallback cost shape retained for callers that need BytePlus defaults. */
export const BYTEPLUS_DEFAULT_COST = {
input: 0.0001,
output: 0.0002,
cacheRead: 0,
cacheWrite: 0,
};
/** BytePlus general model catalog entries. */
export const BYTEPLUS_MODEL_CATALOG: ModelDefinitionConfig[] = BYTEPLUS_MANIFEST_PROVIDER.models;
/** BytePlus coding/planning model catalog entries. */

View File

@@ -1,5 +1,4 @@
export declare function isBundleHashInputPath(filePath: string, repoRoot?: string): boolean;
export declare function getLocalRolldownCliCandidates(repoRoot?: string): string[];
export declare function getBundleHashRepoInputPaths(repoRoot?: string): string[];
export declare function getBundleHashInputPaths(repoRoot?: string): string[];
export declare function compareNormalizedPaths(left: string, right: string): number;

View File

@@ -18,9 +18,7 @@ const require = createRequire(import.meta.url);
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const rootPackageFile = path.join(rootDir, "package.json");
const lockFile = path.join(rootDir, "pnpm-lock.yaml");
const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir];
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
normalizePath(path.relative(rootDir, inputPath)),
);
@@ -77,11 +75,6 @@ export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
];
}
/** Returns A2UI bundle hash input paths. */
export function getBundleHashInputPaths(repoRoot = rootDir) {
return getBundleHashRepoInputPaths(repoRoot);
}
/** Compares paths after normalizing separators to POSIX slashes. */
export function compareNormalizedPaths(left, right) {
const normalizedLeft = normalizePath(left);

View File

@@ -3,7 +3,6 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
compareNormalizedPaths,
getBundleHashInputPaths,
getBundleHashRepoInputPaths,
getLocalRolldownCliCandidates,
isBundleHashInputPath,
@@ -52,7 +51,7 @@ describe("scripts/bundle-a2ui.mjs", () => {
it("keeps local node_modules state out of bundle hash inputs", () => {
const repoRoot = process.cwd();
const inputPaths = getBundleHashInputPaths(repoRoot);
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
expect(inputPaths).not.toContain(path.join(repoRoot, "node_modules", "lit", "package.json"));
expect(inputPaths).not.toContain(

View File

@@ -2,6 +2,42 @@
import { describe, expect, it, vi } from "vitest";
import { loginChutes } from "./oauth.js";
function boundedErrorResponse(body: string, status = 500): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(body);
let read = false;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("response.text() should not be called");
});
const response = {
ok: false,
status,
headers: new Headers(),
body: {
getReader: () => ({
read: async () => {
if (read) {
return { done: true, value: undefined };
}
read = true;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
text,
} as unknown as Response;
return { response, cancel, releaseLock, text };
}
describe("chutes plugin OAuth", () => {
it("rejects unsafe token lifetimes before storing credentials", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
@@ -33,4 +69,47 @@ describe("chutes plugin OAuth", () => {
}),
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
});
it("bounds token exchange error bodies without requiring response.text()", async () => {
const errorResponse = boundedErrorResponse(
`${"chutes token unavailable ".repeat(1024)}tail-marker`,
502,
);
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://api.chutes.ai/idp/token") {
return errorResponse.response;
}
return new Response("not found", { status: 404 });
});
let error: unknown;
try {
await loginChutes({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
manual: true,
createState: () => "state_test",
onAuth: vi.fn(async () => {}),
onPrompt: vi.fn(
async () => "http://127.0.0.1:1456/oauth-callback?code=code_test&state=state_test",
),
fetchFn,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("Chutes token exchange failed: chutes token unavailable");
expect(message).not.toContain("tail-marker");
expect(errorResponse.text).not.toHaveBeenCalled();
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,11 +8,13 @@ import {
parseOAuthCallbackInput,
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHUTES_AUTHORIZE_ENDPOINT = "https://api.chutes.ai/idp/authorize";
const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token";
const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo";
const CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type OAuthPrompt = {
message: string;
@@ -152,7 +154,11 @@ async function exchangeChutesCodeForTokens(params: {
body,
});
if (!response.ok) {
throw new Error(`Chutes token exchange failed: ${await response.text()}`);
const detail = await readResponseTextLimited(
response,
CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES,
).catch(() => "");
throw new Error(`Chutes token exchange failed: ${detail}`);
}
const data = (await response.json()) as {

View File

@@ -5,7 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedClickClackAccount } from "./types.js";
class FakeSocket extends EventEmitter {
emitErrorOnClose = false;
close = vi.fn(() => {
if (this.emitErrorOnClose) {
this.emit("error", new Error("socket closed while connecting"));
}
this.emit("close");
});
}
@@ -191,6 +196,46 @@ describe("ClickClack gateway", () => {
await run;
});
it("reconnects after ClickClack websocket errors", async () => {
const firstSocket = new FakeSocket();
firstSocket.emitErrorOnClose = true;
const secondSocket = new FakeSocket();
mocks.client.websocket.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
firstSocket.emit("error", new Error("gateway dropped"));
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(2));
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: gateway dropped",
);
abort.abort();
await run;
});
it("does not log reconnect warnings when abort closes a connecting websocket", async () => {
const socket = new FakeSocket();
socket.emitErrorOnClose = true;
mocks.client.websocket.mockReturnValue(socket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
abort.abort();
await run;
expect(ctx.log?.warn).not.toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: socket closed while connecting",
);
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
});
it("clears running status when backlog polling fails", async () => {
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
const abort = new AbortController();

View File

@@ -170,11 +170,23 @@ export async function startClickClackGatewayAccount(
}
const socket = client.websocket(workspaceId, afterCursor);
await new Promise<void>((resolve, reject) => {
const abort = () => {
socket.close();
let settled = false;
let removeAbortListener: (() => void) | undefined;
const finishSocketCycle = () => {
if (settled) {
return;
}
settled = true;
removeAbortListener?.();
removeAbortListener = undefined;
resolve();
};
const abort = () => {
socket.close();
finishSocketCycle();
};
ctx.abortSignal.addEventListener("abort", abort, { once: true });
removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
socket.on("message", (data) => {
void (async () => {
const event = parseSocketEvent(data);
@@ -194,11 +206,20 @@ export async function startClickClackGatewayAccount(
});
})().catch(reject);
});
socket.on("close", () => {
ctx.abortSignal.removeEventListener("abort", abort);
resolve();
socket.on("close", finishSocketCycle);
socket.on("error", (error) => {
if (settled || ctx.abortSignal.aborted) {
finishSocketCycle();
return;
}
ctx.log?.warn?.(
`[${account.accountId}] ClickClack websocket error; reconnecting: ${
error instanceof Error ? error.message : String(error)
}`,
);
finishSocketCycle();
socket.close();
});
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("raw response.text() should not be used");
});
const response = {
ok: false,
status: 502,
text,
body: {
getReader: () => ({
read: async () => {
if (readCount > 0) {
return { done: true, value: undefined };
}
readCount += 1;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
} as unknown as Response;
return {
response,
cancel,
releaseLock,
text,
expectedDetail: body.slice(0, limit),
};
}
describe("ClickClack HTTP client", () => {
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);
const client = createClickClackClient({
baseUrl: "https://clickclack.example",
token: "test-token",
fetch: fetchMock,
});
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
expect(streamed.text).not.toHaveBeenCalled();
expect(streamed.cancel).toHaveBeenCalledTimes(1);
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
});
});

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