Compare commits

..

547 Commits

Author SHA1 Message Date
Alex Knight
fd09d2e7d0 fix(compaction): anchor forced manual boundary off trailing tool results 2026-06-17 21:21:25 +10:00
Alex Knight
3aecc4ee9d Force manual compaction past empty kept-tail cuts 2026-06-17 21:21:25 +10:00
Ayaan Zaidi
02330f372c fix(qa): use writable tmp in Telegram package runner
Set TMPDIR=/tmp inside the package Telegram Docker runner so runtime scratch files are written to a writable container path.

Proof:
- pnpm test test/scripts/npm-telegram-live.test.ts
- git diff --check
2026-06-17 16:45:34 +05:30
Vincent Koc
5645dd4d22 refactor(agents): delete unused helper paths 2026-06-17 19:11:20 +08:00
Alex Knight
5a7857dc18 feat(agents): trace compaction summarization model calls
Compaction summarization consumes the model stream via result() only (no
iteration), so it never emitted model.call diagnostic spans. Observe the
stream's result() in the diagnostic wrapper and wire the wrapper into the
direct compaction path so these LLM calls are traced (request/response
content, byte accounting, traceparent).

Decouple underlying-iterator cleanup from terminal-event dedup. The agent
loop awaits result() on the terminal event then abandons the iterator, so
once result() also emits the terminal event, gating safeReturnIterator on
terminalEventEmitted skipped provider cleanup (idle-timeout abort listeners
on the long-lived run signal, SSE readers). Track iterator settlement
separately so return() cleanup always runs; emit dedup stays on
terminalEventEmitted.

Parent compaction model-call spans to the active run/harness trace rather
than a phantom child trace that emits no span of its own.
2026-06-17 21:06:44 +10:00
Vincent Koc
25bd8a7191 fix(ci): install docker heartbeat traps before launch 2026-06-17 19:04:31 +08:00
nas
df87b40bec fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers (#93938)
Merged via squash.

Prepared head SHA: 583b22354d
Co-authored-by: Nas01010101 <156536069+Nas01010101@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 18:56:25 +08:00
joshavant
5d9c010628 ci: add security-sensitive file guard 2026-06-17 12:50:18 +02:00
Vincent Koc
03ca096e84 test(qa): cover otel smoke safety checks 2026-06-17 12:42:28 +02:00
joshavant
22ddf87d2c docs: explain Android signing sync 2026-06-17 12:37:29 +02:00
joshavant
2147312aa2 android: add release signing sync 2026-06-17 12:37:29 +02:00
Vincent Koc
9698070939 fix(qa): allow safe otel log bodies 2026-06-17 12:33:45 +02:00
Vincent Koc
1c0b38f960 fix(sdk): refresh plugin surface baselines 2026-06-17 12:25:42 +02:00
Vincent Koc
0842cb71eb refactor(runtime): hide default constants 2026-06-17 18:20:05 +08:00
Vincent Koc
392bd16a1d refactor(config): hide io constants 2026-06-17 18:14:08 +08:00
Vincent Koc
f3050ab614 refactor(config): hide default constants 2026-06-17 18:11:28 +08:00
Vincent Koc
6e798c02d8 fix(codex): refresh app server protocol mirrors 2026-06-17 12:10:52 +02:00
Vincent Koc
911cd683d5 refactor(commands): hide onboarding defaults 2026-06-17 18:08:16 +08:00
Vincent Koc
4637b65470 refactor(agents): hide compaction warning helpers 2026-06-17 18:05:16 +08:00
Vincent Koc
e2b6753b87 fix(qa-lab): bound credential payload reads 2026-06-17 11:59:55 +02:00
Vincent Koc
366ef93641 test(agents): inline auth profile ordering fixtures 2026-06-17 17:56:13 +08:00
Vincent Koc
dc881a6a31 refactor(acp): hide policy helpers 2026-06-17 17:53:31 +08:00
Vincent Koc
ea72a3382d refactor(acp): remove file event ledger runtime 2026-06-17 17:50:53 +08:00
Vincent Koc
19677bd4ef refactor(acp): hide permission relay helpers 2026-06-17 17:47:13 +08:00
Vincent Koc
9c9c884526 refactor(entry): hide respawn internals 2026-06-17 17:44:12 +08:00
Vincent Koc
120fd2f702 refactor(cli): hide shell support internals 2026-06-17 17:41:44 +08:00
Vincent Koc
582c2d41b9 fix(msteams): unwrap adaptive card submit data 2026-06-17 11:40:52 +02:00
Vincent Koc
30955d3660 refactor(channels): narrow status helper exports 2026-06-17 17:33:40 +08:00
Vincent Koc
5370e73ee9 refactor(channels): hide internal channel types 2026-06-17 17:31:04 +08:00
Vincent Koc
cf7850040e fix(codex): align network proxy profile config 2026-06-17 17:27:34 +08:00
Vincent Koc
1380a9e094 refactor(auto-reply): hide local reply types 2026-06-17 17:23:32 +08:00
Vincent Koc
5055f32ee3 refactor(auto-reply): hide internal command types 2026-06-17 17:18:39 +08:00
Vincent Koc
1075f3819c refactor(utils): narrow helper exports 2026-06-17 17:13:29 +08:00
Vincent Koc
c09ed1954f refactor(utils): trim delivery queue helpers 2026-06-17 17:10:12 +08:00
joshavant
5372c7146b android: add release preflight lane 2026-06-17 11:05:53 +02:00
joshavant
529150868c android: derive release notes from changelog 2026-06-17 11:05:53 +02:00
Vincent Koc
08e0b8cf6b refactor(utils): hide usage pricing types 2026-06-17 17:02:24 +08:00
Vincent Koc
5c34695491 feat(codex): support app-server network proxy profiles (#93538)
Merged via squash.

Prepared head SHA: 9900b14dd5
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-17 17:01:47 +08:00
Vincent Koc
f3ae525211 refactor(wizard): hide prompt session types 2026-06-17 16:59:00 +08:00
Vincent Koc
d47371d9c4 refactor(video): trim runtime helper exports 2026-06-17 16:56:47 +08:00
Vincent Koc
b962c53e78 refactor(web): trim runtime helper exports 2026-06-17 16:53:46 +08:00
Vincent Koc
4ae94d1d46 refactor(trajectory): trim trace config exports 2026-06-17 16:48:13 +08:00
Vincent Koc
102c1f4ec7 refactor(utils): trim helper type exports 2026-06-17 16:42:06 +08:00
Vincent Koc
71645bb8a3 refactor(tasks): trim registry helper exports 2026-06-17 16:37:22 +08:00
Momo
db4bcd7d09 Expose verified ClawHub source in skill verify output (#93532)
* fix(skills): expose verified ClawHub source in verify output

* fix(ci): repair verify check regressions

* fix(ci): refresh prompt snapshots

* fix(skills): require pinned ClawHub verify commits
2026-06-17 16:35:36 +08:00
Vincent Koc
745b011632 refactor(tasks): hide flow audit internals 2026-06-17 16:34:14 +08:00
Vincent Koc
a18cbcb7c6 refactor(status): hide internal helpers 2026-06-17 16:31:42 +08:00
Vincent Koc
2ca375fc1a fix(codex): rotate mcp bindings before transient search 2026-06-17 10:30:46 +02:00
Vincent Koc
22356395a2 fix(codex): drop unused transient binding assignment 2026-06-17 10:30:46 +02:00
Vincent Koc
759b7902ee test(codex): dedupe context engine binding helper 2026-06-17 10:30:46 +02:00
Vincent Koc
f1c44e2d6d fix(codex): rotate mismatched bindings before transient search 2026-06-17 10:30:46 +02:00
Vincent Koc
ffeccce5f9 test(telegram): isolate model callback session state 2026-06-17 10:30:45 +02:00
Vincent Koc
d4fb49f3c4 test(channels): update command output progress expectations 2026-06-17 10:30:45 +02:00
Vincent Koc
f7178a74ef test(feishu): expect send receipts 2026-06-17 10:30:45 +02:00
Vincent Koc
da67802baf fix(codex): respect lifecycle mismatch rotations 2026-06-17 10:30:45 +02:00
Vincent Koc
5b46a11d2d fix(telegram): preserve default model and sticker cache state 2026-06-17 10:30:45 +02:00
Vincent Koc
5ee0f13a54 fix(channels): reset completed command output detail 2026-06-17 10:30:45 +02:00
Vincent Koc
3f18ee4567 refactor(skills): hide workshop internals 2026-06-17 16:28:40 +08:00
Vincent Koc
5b1ba437ba refactor(skills): trim loader export surface 2026-06-17 16:25:01 +08:00
Vincent Koc
4132ce155e fix(cohere): translate system prompts 2026-06-17 16:23:52 +08:00
Vincent Koc
91bcc4cf2a docs(i18n): add Cohere glossary entries 2026-06-17 16:23:52 +08:00
Vincent Koc
a079d98eb4 docs(plugins): refresh Cohere inventory 2026-06-17 16:23:52 +08:00
Vincent Koc
85d5d94519 feat(cohere): add provider plugin 2026-06-17 16:23:52 +08:00
Vincent Koc
cb1e4356aa refactor(skills): hide clawhub lifecycle internals 2026-06-17 16:20:09 +08:00
Alix-007
93e3bcef7a fix(cli): clarify MCP list registry scope (#87487)
Clarify that `openclaw mcp list`, `show`, `set`, and `unset` manage the OpenClaw `mcp.servers` registry and do not include the separate mcporter registry.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-17 10:18:18 +02:00
Vincent Koc
8decb546f7 refactor(skills): hide status upload internals 2026-06-17 16:16:55 +08:00
Vincent Koc
e99a6d4c19 refactor(skills): hide internal result types 2026-06-17 16:12:06 +08:00
Vincent Koc
19c7731292 fix(plugins): classify npm-pack security events as archives 2026-06-17 16:11:32 +08:00
Vincent Koc
3c64a575dd fix(gateway): classify pairing rejection events 2026-06-17 16:11:32 +08:00
Vincent Koc
81df1b239b fix(plugins): satisfy install security lint 2026-06-17 16:11:32 +08:00
Vincent Koc
80c47ecb99 test(plugins): narrow npm install mock options 2026-06-17 16:11:32 +08:00
Vincent Koc
41a0b8df36 fix(agents): classify tool block security events 2026-06-17 16:11:32 +08:00
Vincent Koc
122f29e5ea fix(plugins): preserve install security provenance 2026-06-17 16:11:32 +08:00
Vincent Koc
b6714bf109 fix(diagnostics): preserve plugin security identities 2026-06-17 16:11:32 +08:00
Vincent Koc
df86f36a57 fix(agents): emit inline exec approval decisions 2026-06-17 16:11:32 +08:00
Vincent Koc
7279f43bbb fix(plugins): avoid duplicate npm install security events 2026-06-17 16:11:32 +08:00
Vincent Koc
f51b52ceca fix(diagnostics): narrow security severity text 2026-06-17 16:11:32 +08:00
Vincent Koc
10da9ae248 fix(diagnostics): satisfy security severity lint 2026-06-17 16:11:32 +08:00
Vincent Koc
49e95c5308 fix(logging): project security diagnostics for stability 2026-06-17 16:11:32 +08:00
Vincent Koc
7de5bdca19 test(diagnostics): satisfy security event fixture types 2026-06-17 16:11:32 +08:00
Vincent Koc
7a880bcf29 feat(security): emit audit summary events 2026-06-17 16:11:32 +08:00
Vincent Koc
b86b891326 feat(plugins): emit security events for installs 2026-06-17 16:11:32 +08:00
Vincent Koc
481fd10988 feat(agents): emit security events for exec approvals 2026-06-17 16:11:32 +08:00
Vincent Koc
299d31c56e feat(gateway): emit security events for auth handshakes 2026-06-17 16:11:32 +08:00
Vincent Koc
d6774e46e0 feat(gateway): emit security events for device pairing 2026-06-17 16:11:32 +08:00
Vincent Koc
d491018a45 feat(agents): emit security events for tool vetoes 2026-06-17 16:11:32 +08:00
Vincent Koc
f3a1d1fcb0 feat(diagnostics): export security events to OTLP logs 2026-06-17 16:11:32 +08:00
Vincent Koc
6456d03868 feat(diagnostics): add trusted security events 2026-06-17 16:11:32 +08:00
Alex Knight
e68db3a1b8 fix(config): remove unnecessary dm warning conversion 2026-06-17 18:07:35 +10:00
Alex Knight
57b66b2ec8 fix(config): tighten dm policy warnings 2026-06-17 18:07:35 +10:00
Alex Knight
90e72a67a3 fix(config): resolve DM allowFrom via canonical-or-legacy before warning
The generic dmPolicy/allowFrom warning read only the canonical top-level
allowFrom, so channels that keep their wildcard under the legacy dm.allowFrom
alias (e.g. Discord/Slack, mode=topOnly/topOrNested) got a false 'all DMs
dropped' warning even though runtime honors dm.allowFrom. Resolve policy and
allowFrom through the shared resolveChannelDm* helpers with the channel's
dmAllowFromMode (matching runtime and doctor), and skip nestedOnly channels
whose canonical fields live under dm.* and do not match this warning's
top-level paths. Adds a Discord legacy-alias regression test.

Addresses ClawSweeper review finding P1 (false positives on legacy dm.allowFrom).
2026-06-17 18:07:35 +10:00
Alex Knight
6810c67f0c refactor(config): make DM policy/allowFrom validation generic across channels
Replace the hardcoded Mattermost-only open-DM config check with a generic,
plugin-agnostic warning driven by a single shared evaluator
(evaluateDmPolicyAllowFromDependency) reused by the Zod refinements and the
CLI validator. Surface warnings at 'config validate' and on config load.
Remove the Mattermost-specific status-issues module now covered generically;
keep the runtime drop-log diagnostic.
2026-06-17 18:07:35 +10:00
Alex Knight
ba91eb7acf Fix Mattermost open DM validation 2026-06-17 18:07:35 +10:00
Vincent Koc
c12d921291 refactor(shared): trim helper constants 2026-06-17 16:06:41 +08:00
Vincent Koc
884a6a113c refactor(shared): hide helper option types 2026-06-17 16:03:38 +08:00
Vincent Koc
bed5bf339e fix(sdk): refresh plugin api baseline 2026-06-17 10:00:29 +02:00
Vincent Koc
d1923085e3 refactor(shared): hide parser helper internals 2026-06-17 15:59:58 +08:00
Vincent Koc
3b4808100d refactor(sessions): hide internal helper types 2026-06-17 15:56:51 +08:00
Vincent Koc
0455028a3c refactor(agents): trim session tool internals 2026-06-17 15:53:58 +08:00
Vincent Koc
e0b1cb76e0 refactor(agents): trim session helper exports 2026-06-17 15:50:28 +08:00
Vincent Koc
be4c541176 refactor(agents): trim sandbox helper exports 2026-06-17 15:43:09 +08:00
Vincent Koc
cbf6f0001b refactor(agents): narrow runner harness helper types 2026-06-17 15:38:50 +08:00
heichl_xydigit
bda7581126 fix(feishu): fetch quoted content before empty-message guard (#90192)
* fix(feishu): fetch quoted content before empty-message guard

Moves the quoted/replied message content fetching before the empty-message
early return so a reply with only @bot mention (no text, no media) is not
dropped when it quotes a message with meaningful content. The guard now also
checks that quoted text is empty before skipping.

Note: because the fetch is now unconditional on parentId after passing the
group admission/mention gate, an empty-text reply that quotes a parent in an
open group (requireMention: false) without mentioning the bot will now be
dispatched, where before it was dropped. This is the intended behavior for
open groups — any non-empty turn (including one where context comes from a
quote) should reach the agent. For requireMention:true groups, unmentioned
messages still exit at the mention gate before the fetch, so no over-fetch
occurs.

Adds group-based regression tests for the #90177 scenario:
- Positive: mention-only reply in requireMention:true group with quoted
  parent — dispatches with [Replying to: "..."] in the body.
- Negative: empty reply with no bot mention in requireMention:true group —
  getMessageFeishu is never called and nothing is dispatched.

* fix(feishu): fetch quoted content before empty-message guard (#90192) (thanks @bladin)

---------

Co-authored-by: 黑承亮0668000844 <bladin@users.noreply.github.com>
Co-authored-by: sliverp <870080352@qq.com>
2026-06-17 15:34:23 +08:00
Vincent Koc
e349bdb949 refactor(agents): narrow command helper types 2026-06-17 15:33:16 +08:00
Vincent Koc
768704e906 refactor(agents): hide sqlite cache store internals 2026-06-17 15:30:50 +08:00
Vincent Koc
ba1403604d refactor(agents): remove external auth oauth aliases 2026-06-17 15:28:44 +08:00
Vincent Koc
e939963784 refactor(agents): trim provider config helper exports 2026-06-17 15:26:19 +08:00
Vincent Koc
5ff7242391 refactor(agents): trim live media classifiers 2026-06-17 15:20:09 +08:00
Vincent Koc
c25a4e6d0b refactor(agents): trim runtime constant exports 2026-06-17 15:16:50 +08:00
Vincent Koc
4ea1b4fc4a refactor(agents): trim exec helper exports 2026-06-17 15:13:35 +08:00
Vincent Koc
3881cb3426 refactor(agents): trim media helper exports 2026-06-17 15:10:01 +08:00
Vincent Koc
bfd11ee29f refactor(agents): trim session helper facades 2026-06-17 15:06:19 +08:00
Vincent Koc
664948e7bf refactor(agents): hide tool helper internals 2026-06-17 15:01:41 +08:00
Vincent Koc
8142c12db2 fix(agents): route BTW through canonical Codex runtime (#93881)
Merged via squash.

Prepared head SHA: e88fb50880
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 14:58:32 +08:00
Vincent Koc
963783e3be refactor(auto-reply): hide command turn helper types 2026-06-17 14:54:41 +08:00
Vincent Koc
283e8cf793 refactor(agents): hide tool provider helper types 2026-06-17 14:49:43 +08:00
Vincent Koc
78f7ef88eb ci: allow longer testbox helper runs 2026-06-17 08:47:42 +02:00
Vincent Koc
abee98feaa refactor(agents): hide media tool helper exports 2026-06-17 14:46:41 +08:00
Vincent Koc
53ff3085f9 refactor(agents): hide sandbox helper types 2026-06-17 14:44:11 +08:00
Vincent Koc
e2fa4f396b refactor(agents): hide settings helper types 2026-06-17 14:39:39 +08:00
Vincent Koc
2d91aaa9ed refactor(agents): hide steering queue types 2026-06-17 14:34:11 +08:00
Vincent Koc
e15dadec64 refactor(agents): drop runtime context re-export 2026-06-17 14:32:08 +08:00
Vincent Koc
18aa327655 refactor(agents): drop preemptive route re-export 2026-06-17 14:30:10 +08:00
Vincent Koc
94e79a052c refactor(agents): hide message merge helper types 2026-06-17 14:28:14 +08:00
Vincent Koc
0804901c11 refactor(agents): hide failover policy types 2026-06-17 14:26:24 +08:00
Vincent Koc
7b03f11084 test(agents): hide spawn workspace support internals 2026-06-17 14:24:22 +08:00
Vincent Koc
812dcc5d4d refactor(agents): hide bootstrap context type 2026-06-17 14:22:17 +08:00
Vincent Koc
0b698709d8 refactor(agents): hide message transform type 2026-06-17 14:14:22 +08:00
Vincent Koc
58ec07c598 refactor(agents): hide bootstrap routing types 2026-06-17 14:12:28 +08:00
Vincent Koc
7aac97c1a9 refactor(agents): hide prompt helper types 2026-06-17 14:09:03 +08:00
Vincent Koc
d9c4f9a964 refactor(agents): hide session owner timeout error 2026-06-17 14:06:55 +08:00
Vincent Koc
f06539d8ba refactor(agents): hide queue message timeout 2026-06-17 14:05:01 +08:00
Vincent Koc
9e3db6bedd refactor(agents): remove unused compaction reserve helper 2026-06-17 14:03:05 +08:00
Vincent Koc
580bba0637 refactor(agents): remove unused transcript runtime helpers 2026-06-17 13:57:07 +08:00
Ayaan Zaidi
68eb5031bd fix(telegram): surface rich-message disabled state 2026-06-17 11:26:52 +05:30
Vincent Koc
aca48b55ad refactor(agents): drop stream resolution test alias 2026-06-17 13:55:05 +08:00
Vincent Koc
69abb2c090 fix(codex): send legacy dynamic tool start specs 2026-06-17 07:54:25 +02:00
Vincent Koc
6ee989a235 refactor(agents): drop stale model alias re-export 2026-06-17 13:53:22 +08:00
Vincent Koc
d4e67ebc9a refactor(agents): hide extra params test internals 2026-06-17 13:51:36 +08:00
Vincent Koc
05bbe75212 refactor(agents): hide compaction harness mocks 2026-06-17 13:49:39 +08:00
Vincent Koc
a48a9bbd7d refactor(agents): hide provider error pattern internals 2026-06-17 13:48:22 +08:00
Vincent Koc
e655357197 refactor(agents): hide thinking recovery type 2026-06-17 13:46:59 +08:00
Vincent Koc
c3c4d44f6e refactor(agents): hide manual compaction boundary type 2026-06-17 13:45:37 +08:00
Vincent Koc
0a314c61b1 fix(ci): remove unused cross-spawn dependency 2026-06-17 13:43:14 +08:00
Vincent Koc
c1ac18e481 refactor(agents): hide attempt policy types 2026-06-17 13:42:31 +08:00
Vincent Koc
956856ae07 refactor(agents): hide attempt helper types 2026-06-17 13:40:14 +08:00
Vincent Koc
c624ae49db refactor(agents): hide compaction helper types 2026-06-17 13:37:50 +08:00
Vincent Koc
074a4ef7e6 refactor(agents): hide runner transcript types 2026-06-17 13:35:46 +08:00
Vincent Koc
31b69a1256 refactor(agents): hide runner guard types 2026-06-17 13:34:05 +08:00
Vincent Koc
44a4b21d9c refactor(agents): hide runner helper types 2026-06-17 13:31:56 +08:00
Vincent Koc
1474f4af2b refactor(agents): hide runner evidence internals 2026-06-17 13:30:01 +08:00
Vincent Koc
eebb5d73f4 refactor(agents): hide cache ttl entry type 2026-06-17 13:28:24 +08:00
Vincent Koc
c784f649b1 test(agents): drop unused oauth store helper 2026-06-17 13:26:36 +08:00
Vincent Koc
cea318bcc6 fix(ci): remove unused child process import 2026-06-17 13:26:09 +08:00
Vincent Koc
7186f0d654 refactor(agents): trim auth persistence exports 2026-06-17 13:22:49 +08:00
Vincent Koc
0479b9ed5d refactor(agents): hide oauth refresh helper type 2026-06-17 13:21:06 +08:00
Vincent Koc
2c3c4b0122 refactor(agents): drop stale auth constants 2026-06-17 13:19:37 +08:00
Vincent Koc
834c7c2e47 refactor(agents): hide auth profile helper types 2026-06-17 13:17:19 +08:00
Vincent Koc
f0488dd6aa refactor(agents): trim interactive helper exports 2026-06-17 13:15:37 +08:00
Vincent Koc
47059e4ebc refactor(channels): hide local helper types 2026-06-17 13:12:32 +08:00
Vincent Koc
8ea5342c99 refactor(agents): trim utility helper exports 2026-06-17 13:09:17 +08:00
Vincent Koc
cda11ced07 refactor(auto-reply): hide delivery helper types 2026-06-17 13:04:43 +08:00
Vincent Koc
20ef410d64 fix(ci): remove unused Claude permission type 2026-06-17 13:01:33 +08:00
Vincent Koc
3d3d8a5bef refactor(agents): hide session tool helper types 2026-06-17 13:00:14 +08:00
Vincent Koc
0baaa63def refactor(agents): hide helper metadata types 2026-06-17 12:56:04 +08:00
Vincent Koc
c0c1a92967 refactor(acp): remove unused reset helper 2026-06-17 12:52:45 +08:00
Vincent Koc
f9439715e9 refactor(acp): hide helper types 2026-06-17 12:48:07 +08:00
Vincent Koc
8ad356403e refactor(media): hide understanding helper types 2026-06-17 12:42:33 +08:00
Vincent Koc
d9d4da0608 test(codex): seed context-engine web search binding 2026-06-17 06:40:24 +02:00
Vincent Koc
7758f5e224 refactor(media): hide MCP media helper types 2026-06-17 12:40:00 +08:00
Vincent Koc
54bcdea342 refactor(logging): hide diagnostic capture type 2026-06-17 12:36:52 +08:00
Vincent Koc
dbb62bba85 refactor(logging): hide stability evidence types 2026-06-17 12:35:19 +08:00
Vincent Koc
0c6ebcd6c0 refactor(logging): hide diagnostic bundle types 2026-06-17 12:33:20 +08:00
Vincent Koc
22405223c2 refactor(logging): hide recovery helper types 2026-06-17 12:31:19 +08:00
Vincent Koc
f8fc316b0c refactor(infra): hide backup helper types 2026-06-17 12:29:14 +08:00
Vincent Koc
e098eb735f refactor(infra): hide utility option types 2026-06-17 12:26:37 +08:00
Vincent Koc
a0a0e5e4cb refactor(infra): hide local helper types 2026-06-17 12:23:59 +08:00
Vincent Koc
ada70ece6f refactor(gateway): hide websocket helper types 2026-06-17 12:21:22 +08:00
Vincent Koc
d7d4852e5e refactor(gateway): hide local helper types 2026-06-17 12:18:54 +08:00
Vincent Koc
cc451f98cb refactor(config): hide session helper types 2026-06-17 12:15:59 +08:00
Vincent Koc
04255b247c revert(providers): remove ClawRouter provider 2026-06-17 12:15:17 +08:00
Vincent Koc
97b9bd1d81 refactor(config): hide local helper types 2026-06-17 12:13:51 +08:00
Vincent Koc
71ce525c69 refactor(commands): hide migrate helper types 2026-06-17 12:11:47 +08:00
Vincent Koc
f559b75918 refactor(commands): hide doctor helper types 2026-06-17 12:09:48 +08:00
Vincent Koc
5c3a29a1c2 refactor(commands): trim status type exports 2026-06-17 12:05:11 +08:00
Vincent Koc
62fad3da86 fix(update): use configured npm registry for update metadata (#93879)
Merged via squash.

Prepared head SHA: ae8bbb0303
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-17 12:04:44 +08:00
Vincent Koc
f33cf5c866 refactor(commands): hide custom provider helper types 2026-06-17 12:02:40 +08:00
Alix-007
4559a8d736 fix(cron): reject invalid absolute timestamps (#93903)
* fix(cron): reject invalid absolute timestamps

* fix(cron): preserve ISO end of day

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 12:00:53 +08:00
Vincent Koc
087e3f56dc refactor(commands): trim custom provider type reexports 2026-06-17 12:00:23 +08:00
Vincent Koc
3f25c578c1 refactor(flows): hide setup helper types 2026-06-17 11:58:15 +08:00
Vincent Koc
a73f026c2d fix(macos): preserve approvals migration data (#93880)
Merged via squash.

Prepared head SHA: a8a0dd0cbb
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-17 11:57:32 +08:00
Vincent Koc
508ce22468 refactor(commands): trim setup barrel type exports 2026-06-17 11:55:52 +08:00
Vincent Koc
9911a682f4 refactor(commands): trim doctor probe type exports 2026-06-17 11:53:39 +08:00
Vincent Koc
0c909ea97f refactor(commands): hide doctor state helper types 2026-06-17 11:50:49 +08:00
Vincent Koc
9b8102d774 refactor(commands): hide doctor utility types 2026-06-17 11:48:43 +08:00
Vincent Koc
922aea7d28 fix(sdk): refresh plugin api baseline 2026-06-17 05:47:07 +02:00
Vincent Koc
8c6139006a fix(providers): align ClawRouter package boundary 2026-06-17 11:46:57 +08:00
Vincent Koc
ed88457f3b fix(providers): compose ClawRouter native auth 2026-06-17 11:46:57 +08:00
Vincent Koc
c6d4c5299a fix(providers): wrap direct fallback streams 2026-06-17 11:46:57 +08:00
Vincent Koc
5baca82072 fix(providers): apply wrappers to direct streams 2026-06-17 11:46:57 +08:00
Vincent Koc
d667dcfb90 fix(providers): route ClawRouter direct streams 2026-06-17 11:46:57 +08:00
Vincent Koc
ad81cb44ba fix(providers): require runnable ClawRouter Gemini routes 2026-06-17 11:46:57 +08:00
Vincent Koc
f465ae08e2 fix(providers): preserve ClawRouter catalog model ids 2026-06-17 11:46:57 +08:00
Vincent Koc
5af0ccfd5f fix(providers): cover ClawRouter runtime auth paths 2026-06-17 11:46:57 +08:00
Vincent Koc
8a4d92d362 fix(providers): isolate ClawRouter runtime credentials 2026-06-17 11:46:57 +08:00
Vincent Koc
c4e0d27ade fix(providers): restore ClawRouter native runtime routes 2026-06-17 11:46:57 +08:00
Vincent Koc
8c8866c921 fix(providers): consume canonical ClawRouter catalog field 2026-06-17 11:46:57 +08:00
Vincent Koc
ca2fbece8b chore(deps): register ClawRouter workspace 2026-06-17 11:46:57 +08:00
Vincent Koc
4c8ac47dbe fix(providers): preserve ClawRouter native replay policies 2026-06-17 11:46:57 +08:00
Vincent Koc
d32e241ca0 chore(providers): align ClawRouter package version 2026-06-17 11:46:57 +08:00
Vincent Koc
c83c37b4d2 docs(providers): document ClawRouter integration 2026-06-17 11:46:57 +08:00
Vincent Koc
95dafc824e feat(providers): add ClawRouter managed proxy 2026-06-17 11:46:57 +08:00
Vincent Koc
ee2d4e1f79 refactor(commands): hide doctor repair helper types 2026-06-17 11:46:16 +08:00
Alix-007
d2bf67f4b7 fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview (#93874) 2026-06-17 11:44:19 +08:00
Vincent Koc
12eeb5cb63 refactor(commands): trim setup type reexports 2026-06-17 11:43:46 +08:00
Vincent Koc
83ad2cddee refactor(commands): hide status task helper types 2026-06-17 11:41:25 +08:00
Vincent Koc
66a8d0a7ec fix(ui): harden chromium test runner 2026-06-17 05:39:06 +02:00
Vincent Koc
c48657b920 refactor(commands): hide onboarding install result 2026-06-17 11:38:33 +08:00
Vincent Koc
2a02746bd7 test(codex): refresh dynamic tool snapshots 2026-06-17 11:36:17 +08:00
Vincent Koc
16f66e367c refactor(commands): trim backup type exports 2026-06-17 11:35:50 +08:00
Vincent Koc
e84d68e794 refactor(commands): trim migrate option reexports 2026-06-17 11:32:59 +08:00
Vincent Koc
d320e69326 refactor(commands): trim health type reexports 2026-06-17 11:30:34 +08:00
Vincent Koc
f50812dd56 refactor(commands): hide health summary internals 2026-06-17 11:28:14 +08:00
Vincent Koc
8da30037b3 refactor(commands): hide gateway readiness types 2026-06-17 11:25:32 +08:00
Vincent Koc
09bd5d5d19 refactor(commands): hide doctor service install mock 2026-06-17 11:22:31 +08:00
Vincent Koc
4f1e2efaa1 refactor(commands): hide doctor e2e mock internals 2026-06-17 11:19:45 +08:00
Vincent Koc
d3cf3d70f8 refactor(commands): hide doctor harness mocks 2026-06-17 11:16:29 +08:00
Vincent Koc
d79d5487aa fix(deps): remediate Dependabot alerts (#93857)
Merged via squash.

Prepared head SHA: 51ece24eef
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-17 11:15:31 +08:00
Vincent Koc
d03ef9d717 refactor(tests): hide gateway state helper types 2026-06-17 11:12:22 +08:00
Vincent Koc
490ef68864 refactor(tests): hide helper-only types 2026-06-17 11:08:54 +08:00
Vincent Koc
f0acd91478 refactor(acp): hide test helper internals 2026-06-17 11:06:42 +08:00
Vincent Koc
975d2e9b2b fix(slack): preserve completed native progress titles 2026-06-17 11:06:28 +08:00
Vincent Koc
27c8fae1ce refactor(cli): hide compile cache and media internals 2026-06-17 11:04:10 +08:00
Vincent Koc
d0ae6ead8b fix(agents): tolerate uncloneable adjusted tool params 2026-06-17 05:03:48 +02:00
Vincent Koc
bdbc8c6592 refactor(agents): hide fallback skip cache internals 2026-06-17 11:00:11 +08:00
Vincent Koc
80238595ed refactor(agents): hide context window thresholds 2026-06-17 10:57:15 +08:00
Shakker
0da5861e74 docs: add Gemini CLI auth changelog entry 2026-06-17 03:53:37 +01:00
Vincent Koc
46b6aa9044 refactor(browser): hide cdp reachability defaults 2026-06-17 10:51:41 +08:00
Vincent Koc
3312c7f467 refactor(feishu): hide timeout config type 2026-06-17 10:49:28 +08:00
Vincent Koc
4681a559c0 refactor(qqbot): hide response timeout default 2026-06-17 10:47:01 +08:00
Vincent Koc
6855cbc3df refactor(plugins): hide cleanup timeout internals 2026-06-17 10:45:04 +08:00
Vincent Koc
bfc5e49291 refactor(agents): hide idle timeout default 2026-06-17 10:42:16 +08:00
Vincent Koc
20ea7a055e refactor(agents): remove stale compaction grace helper 2026-06-17 10:40:20 +08:00
Vincent Koc
71fbddd2bb refactor(agents): hide compaction timeout internals 2026-06-17 10:38:05 +08:00
Vincent Koc
16142bebd8 refactor(agents): hide stale run cutoff 2026-06-17 10:36:06 +08:00
Vincent Koc
788eb2e3bf refactor(agents): hide cleanup timeout helpers 2026-06-17 10:33:32 +08:00
Shakker
32d0b9c872 fix: keep Gemini CLI auth out of warmup 2026-06-17 03:31:14 +01:00
Shakker
e074f36168 fix: clean up Gemini CLI backend checks 2026-06-17 03:31:14 +01:00
Shakker
589d3b12dd fix: use OpenClaw temp root for Gemini CLI settings 2026-06-17 03:31:14 +01:00
Shakker
c6b5ef9b20 docs: update Gemini CLI backend defaults 2026-06-17 03:31:14 +01:00
Shakker
b2f7d9ebc8 fix: allow Gemini CLI file edits 2026-06-17 03:31:14 +01:00
Shakker
c6d7d85763 fix: parse Gemini CLI stream output 2026-06-17 03:31:14 +01:00
Shakker
81c9bd3997 fix: add CLI empty response diagnostics 2026-06-17 03:31:14 +01:00
Shakker
9824061241 fix: align Gemini CLI home path 2026-06-17 03:31:14 +01:00
Shakker
0bc384fc03 fix: keep CLI env diagnostics opt-in 2026-06-17 03:31:14 +01:00
Shakker
045a7148d4 fix: select CLI auth profile for runtime prep 2026-06-17 03:31:14 +01:00
Shakker
c6478defba fix: log Gemini CLI runtime env activation 2026-06-17 03:31:14 +01:00
Shakker
17ba3bc65d fix: load staged Gemini CLI auth profiles 2026-06-17 03:31:14 +01:00
Shakker
c38c4e9212 fix: expose CLI runtime env diagnostics 2026-06-17 03:31:14 +01:00
Shakker
2bde35d29c fix: pass prepared CLI env to spawned process 2026-06-17 03:31:14 +01:00
Shakker
0adfca3189 test: narrow Gemini CLI setup agent dir 2026-06-17 03:31:14 +01:00
Shakker
b9d676ce45 test: fix Gemini CLI auth test types 2026-06-17 03:31:14 +01:00
Shakker
c3bd9250c0 fix: honor inherited Gemini auth policy 2026-06-17 03:31:14 +01:00
Shakker
e33113426a fix: preserve ambient Gemini system auth 2026-06-17 03:31:14 +01:00
Shakker
f68b06da46 fix: clean Gemini bundle settings on prepare failure 2026-06-17 03:31:14 +01:00
Shakker
fe61b62c2b fix: keep Gemini CLI system settings per run 2026-06-17 03:31:14 +01:00
Shakker
ce007fbb1e fix: enforce Gemini CLI profile auth precedence 2026-06-17 03:31:14 +01:00
Shakker
cef3293d31 fix: isolate Gemini CLI system auth settings 2026-06-17 03:31:14 +01:00
Shakker
5d69ce6aa4 fix: stage adopted Gemini OAuth credentials 2026-06-17 03:31:14 +01:00
Shakker
6c9fa4ac8c fix: clear ambient Google ADC for Gemini CLI 2026-06-17 03:31:14 +01:00
Shakker
90160b52df fix: stage resolved Gemini OAuth profiles 2026-06-17 03:31:14 +01:00
Shakker
4377bd189d fix: ignore stale auto auth for Gemini CLI 2026-06-17 03:31:14 +01:00
Shakker
91f0767257 fix: keep CLI prepare credentials private 2026-06-17 03:31:14 +01:00
Shakker
d53e559ae7 fix: bind Gemini CLI epochs to profile homes 2026-06-17 03:31:14 +01:00
Shakker
625085187e fix: forward pinned Gemini CLI auth for validation 2026-06-17 03:31:14 +01:00
Shakker
5d0f5473da test: type Gemini CLI auth refresh mock 2026-06-17 03:31:14 +01:00
Shakker
8d3929e86f fix: honor Google auth order for Gemini CLI 2026-06-17 03:31:14 +01:00
Shakker
eb92a0bf76 fix: accept Google API keys for Gemini CLI 2026-06-17 03:31:14 +01:00
Shakker
defaffbb93 fix: keep CLI auth fallback scoped 2026-06-17 03:31:14 +01:00
Shakker
e834249db3 fix: preserve runtime auth alias scope 2026-06-17 03:31:14 +01:00
Shakker
7c276f4ba1 fix: fail closed on unstaged Gemini profiles 2026-06-17 03:31:14 +01:00
Shakker
71c112219f fix: scope CLI auth epochs to agent stores 2026-06-17 03:31:14 +01:00
Shakker
8f66a9028c fix: preserve Gemini CLI project binding 2026-06-17 03:31:14 +01:00
Shakker
6afb08f9ab fix: persist Gemini CLI auth homes 2026-06-17 03:31:14 +01:00
Jason O'Neal
94a4a3fbc4 fix(gemini): bridge OAuth profiles into CLI runtime 2026-06-17 03:31:14 +01:00
Vincent Koc
c1f706d370 refactor(agents): hide timeout seconds helper 2026-06-17 10:28:43 +08:00
Vincent Koc
ab1e5832d2 fix(codex): sync app-server dynamic tool protocol 2026-06-17 04:28:32 +02:00
Vincent Koc
70664e6083 refactor(agents): hide docs path helpers 2026-06-17 10:27:05 +08:00
Vincent Koc
25a7e34e11 refactor(agents): hide custom api source id helper 2026-06-17 10:25:05 +08:00
Vincent Koc
b23022d3af refactor(agents): remove video task status wrappers 2026-06-17 10:23:27 +08:00
Vincent Koc
bd6dc4bdc3 refactor(agents): hide transport error extractor 2026-06-17 10:17:57 +08:00
Vincent Koc
8bef7a214e refactor(agents): hide tool mutation helpers 2026-06-17 10:15:51 +08:00
Vincent Koc
a588a33ffa refactor(agents): hide subagent outcome helpers 2026-06-17 10:13:46 +08:00
Vincent Koc
e209a56d0b refactor(agents): hide copilot routing constants 2026-06-17 10:11:56 +08:00
Vincent Koc
4d3e355a52 refactor(agents): hide context cache reset helper 2026-06-17 10:10:03 +08:00
Vincent Koc
599abac902 refactor(agents): hide compaction reconcile wrapper 2026-06-17 10:06:43 +08:00
Shakker
25ba8e3d35 fix: clean agent lint failures 2026-06-17 03:05:31 +01:00
Vincent Koc
70de1047b8 refactor(agents): hide message handler helpers 2026-06-17 10:04:26 +08:00
Vincent Koc
9dc92156d1 refactor(agents): hide assistant stream delivery types 2026-06-17 10:01:18 +08:00
Josh Lehman
cf64a9c517 clawdbot-d02.1.9.1.31: add sessions.create lifecycle seam (#93691) 2026-06-16 19:01:14 -07:00
Vincent Koc
8b06d80655 fix(e2e): reject unsafe Docker pack names 2026-06-17 03:58:17 +02:00
Vincent Koc
2f222cdc1c refactor(agents): hide execution contract resolver 2026-06-17 09:57:26 +08:00
Vincent Koc
e5ff835c01 refactor(agents): remove catalog browse timer test hooks 2026-06-17 09:55:06 +08:00
Vincent Koc
fbfaba09fd refactor(agents): hide mcp oauth redirect classifier 2026-06-17 09:51:51 +08:00
Vincent Koc
0c651fd082 refactor(agents): drop mcp fetch type re-export 2026-06-17 09:49:52 +08:00
Vincent Koc
66fde5a467 fix(e2e): keep live plugin pack paths local 2026-06-17 03:49:42 +02:00
Vincent Koc
e188350c74 refactor(agents): hide update plan gating helper 2026-06-17 09:47:29 +08:00
Vincent Koc
ce763e6ec9 refactor(agents): hide provider error metadata extractor 2026-06-17 09:45:11 +08:00
Vincent Koc
db02036f8d refactor(agents): hide stream context text transform 2026-06-17 09:43:00 +08:00
Vincent Koc
34dbb11e3e refactor(agents): hide plugin catalog file type 2026-06-17 09:41:08 +08:00
Vincent Koc
2333137d83 fix(release): keep npm preflight pack names local 2026-06-17 03:40:19 +02:00
Vincent Koc
256f224d67 refactor(agents): hide model registry loader options 2026-06-17 09:39:19 +08:00
Alix-007
f8e7d66ae6 fix(reasoning-tags): strip MiniMax mm: tags on silent-reply and streaming paths missed by #93767 (#93806)
* fix(reasoning-tags): accept MiniMax mm: prefix in silent-detection and stream gates

PR #93767 added MiniMax `mm:`-namespaced reasoning-tag support across the
shared sanitizer and Telegram lane coordinator, but two production reasoning-tag
recognizers were missed and still only matched the `antml:` namespace:

- src/auto-reply/tokens.ts: `taggedReasoningPrefixRe` / `openReasoningPrefixRe`
  drive `stripLeadingReasoningBlocks` and `isSilentReplyPayloadText`, which 14+
  call sites use to detect NO_REPLY silent payloads. A `<mm:think>…</mm:think>NO_REPLY`
  reply was not recognized as silent, leaking the wrapper into delivery.
- src/agents/embedded-agent-subscribe.handlers.messages.ts: `REASONING_TAG_RE`
  gates `shouldRecomputeFullStream`. A `<mm:think>` streaming chunk failed the
  test, so the visible stream was not recomputed and the hidden reasoning leaked.

Add the `mm:` alternative alongside `antml:` in all three regexes, matching the
exact `(?:antml:|mm:)?` form used by #93767. Identification-only change, no other
regex logic touched.

* test(agents): cover MiniMax reasoning regressions

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-17 09:38:53 +08:00
liuhao1024
4cd83d26be fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable (fixes #63343) (#93797)
* fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable

When ensureTabAvailable opens a new tab on empty list, the return value
from openTab was discarded. A subsequent listTabs() call may return tabs
without webSocketDebuggerUrl populated yet, causing the wsUrl filter to
eliminate the newly opened tab and throw BrowserTabNotFoundError.

Fix: capture openTab's return value and merge it into candidates if the
wsUrl filter excluded it. openTab's internal discovery loop already
resolves wsUrl, so the returned tab is always valid.

* fix(browser): harden tab selection discovery

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-17 09:38:47 +08:00
ZengWen-DT
5f90f08957 fix(feishu): paginate wiki node and space listing (#37626) (#93796)
* fix(feishu): paginate wiki node and space listing (fixes #37626)

client.wiki.spaceNode.list / wiki.space.list return at most one page (max
50 items); the tool ignored has_more/page_token and silently dropped every
node past the first page. Drain both endpoints via a bounded shared helper
that loops on has_more with a 100-page safety cap.

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

* fix(feishu): expose wiki pagination cursors

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-17 09:38:41 +08:00
Colin Johnson
8e77d5e144 fix(android): wait for node capability approval before onboarding (#93792)
* fix android node approval wait state

* docs: add android approval wait proof

* fix(android): address approval state review cleanup

* docs: move PR proof images out of repo

* test: trim android node approval proof

* fix(android): wait for node approval before onboarding

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-17 09:38:33 +08:00
Vincent Koc
ad0af79ddf refactor(agents): drop provider auth snapshot re-export 2026-06-17 09:37:13 +08:00
Vincent Koc
b3d37f4609 refactor(agents): remove unused image task status wrappers 2026-06-17 09:35:25 +08:00
Vincent Koc
4900881747 refactor(agents): hide google simple completion api id 2026-06-17 09:33:28 +08:00
weiqinl
1abf68f12e fix(ui): preserve WebChat visible messages across session switches (#93803)
* fix(ui): preserve WebChat visible messages across session switches

* fix(ui): scope WebChat session message cache

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-17 09:32:07 +08:00
Vincent Koc
4039f06f70 refactor(agents): remove unused command poll suggestion 2026-06-17 09:31:13 +08:00
Vincent Koc
c7549f5040 fix(release): keep Parallels pack names local 2026-06-17 03:29:45 +02:00
Vincent Koc
65b1638381 refactor(agents): hide subagent list helpers 2026-06-17 09:29:04 +08:00
Hrachya Shaginyan
6567f99625 strip UTF-8 BOM when reading SKILL.md in quick_validate (#93811)
The previous `read_text(encoding="utf-8")` call left the UTF-8 byte
order mark (EF BB BF, three bytes) in the content string if the file
was saved by a tool that emits a BOM. The first line check
(`lines[0].strip() != "---"`) then saw "\ufeff---" and rejected the
file as "Invalid frontmatter format", even though the document was
otherwise valid frontmatter.

Co-authored-by: Zo Bot <github-automation@zo.computer>
2026-06-17 09:28:43 +08:00
Yuval Dinodia
e896ca9634 fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass (#93810)
When more than maxMissedJobsPerRestart cron jobs are overdue after gateway
downtime, runMissedJobs defers the overflow jobs to a near-future staggered
catch-up slot. start()'s second maintenance pass then recomputed each overflow
cron deferral to its natural schedule slot, because it ran future-slot repair
with the default-enabled flag. For a daily 0 9 * * * job the now+stagger
catch-up was clobbered to the next 09:00, dropping the missed run for a full
period.

Scope the exemption instead of disabling repair wholesale: runMissedJobs now
returns the ids it deferred this startup, recomputeNextRunsForMaintenance gains
skipFutureRepairJobIds to exempt exactly those ids, and start() threads them
into its pass. Overflow catch-up deferrals survive until their staggered tick
while ordinary stale-future cron slots are still repaired on startup.
2026-06-17 09:28:24 +08:00
Vincent Koc
8ae4580df6 refactor(agents): remove unused responses image sanitizer export 2026-06-17 09:27:41 +08:00
Vincent Koc
06e2614cf4 refactor(agents): remove unused provider tls helper 2026-06-17 09:26:13 +08:00
Vincent Koc
43400f8d5b refactor(agents): remove duplicate live model error classifier 2026-06-17 09:24:43 +08:00
Vincent Koc
29a647e816 refactor(agents): remove unused openai model ref helper 2026-06-17 09:23:22 +08:00
Vincent Koc
5c62ed8db1 refactor(agents): hide model runtime policy types 2026-06-17 09:22:01 +08:00
Vincent Koc
5b077d549e fix(release): reject unsafe candidate pack names 2026-06-17 03:21:31 +02:00
Vincent Koc
b338a68e57 refactor(agents): hide model ref normalization types 2026-06-17 09:20:46 +08:00
Vincent Koc
4df8237a01 refactor(agents): drop unused session runtime override helper 2026-06-17 09:18:35 +08:00
Vincent Koc
a7ebcfded3 refactor(agents): narrow media factory plan type 2026-06-17 09:17:10 +08:00
Vincent Koc
6dec15b4ff refactor(agents): remove queued writer append alias 2026-06-17 09:13:58 +08:00
Vincent Koc
fafcdb5a74 refactor(agents): narrow openai tool projection types 2026-06-17 09:11:50 +08:00
Vincent Koc
af26d005c9 refactor(agents): narrow anthropic tool projection type 2026-06-17 09:10:05 +08:00
Vincent Koc
53655f39f1 refactor(agents): drop runtime metadata re-export 2026-06-17 09:08:07 +08:00
Vincent Koc
93216e1ca1 fix(sdk): refresh plugin api baseline hash 2026-06-17 03:07:56 +02:00
Vincent Koc
461f0cfc5b fix(release): keep bun smoke tarballs local 2026-06-17 03:07:31 +02:00
Vincent Koc
b832dd27e1 refactor(agents): hide client tool conflict prefix 2026-06-17 09:05:31 +08:00
Alix-007
88334627fe fix(status): show 0 (not ?) for fresh-session context tokens (#93798)
* fix(status): show 0/1.0m instead of ?/1.0m on a fresh session

On a brand-new /new session the persisted totalTokens is absent
(undefined), so /status rendered the context numerator as ? via
formatTokens(null, ...). A fresh session with no usage is a known
zero, not an unknown total, so normalize undefined-but-not-stale
totals to 0 before formatting while leaving the intentional
totalTokensFresh === false stale guard (which must keep ?) intact.

Fixes #93771

* fix(status): persist fresh-session zero usage

* fix(status): identify fresh empty sessions

* fix(status): persist fresh empty session usage

* fix(status): preserve fork and compaction token state

* fix(status): preserve queued compaction token state

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 09:05:02 +08:00
OfflynAI
9fd9aa5fcd fix(agents): defer session suspension across fallback (#93789)
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 09:04:48 +08:00
Yuval Dinodia
891dd037b5 fix(google): keep parallel Gemini tool responses in the turn after the model (#93780)
* fix(google): keep parallel Gemini tool responses in the turn after the model

On Gemini < 3 vision models, a parallel tool-call turn whose non-last result
returns an image split function responses across user turns. The merge heuristic
only inspected contents[last], so the separate "Tool result image:" turn landed
between two parallel responses and stranded the second one in a fresh turn. The
turn right after the model then carried fewer functionResponse parts than the
model issued functionCall parts, so Gemini returned 400 INVALID_ARGUMENT. Because
the malformed turn is persisted, every later turn re-400s and the session sticks.

Replace the contents[last] heuristic with a run-scoped accumulator: all responses
for one model turn merge into the single user turn after it, and Gemini < 3 image
turns defer to the end of the tool-result run so they trail that response turn.
Covers both google.ts and google-vertex.ts, which share this convertMessages.

* fix(google): align provider transport tool result turns

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 09:04:35 +08:00
liuhao1024
b3a1472875 fix(memory): await search-sync before returning results to prevent stale index (fixes #52115) (#93791)
* fix(memory): await search-sync before returning results to prevent stale index

When the gateway process has been running for a while, memory_search
returns stale results because startAsyncSearchSync fires off the index
sync as a background task (void ... .catch()) without waiting for it
to complete. Search results are then read from the old index state.

Change startAsyncSearchSync from sync/fire-and-forget to async/await
so that the index is synced before search results are returned. This
ensures memory_search reflects the current filesystem state, matching
the behavior of the CLI  command which creates
a fresh manager each time.

Fixes #52115

* test(memory): prove search waits for dirty sync

* test(memory): align search with synchronous sync

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 09:04:24 +08:00
Vincent Koc
70023a1183 refactor(agents): hide write lock guard helpers 2026-06-17 09:00:55 +08:00
Vincent Koc
dd7376fdcb fix(release): keep install smoke tarballs local 2026-06-17 03:00:09 +02:00
Vincent Koc
53accb122d refactor(agents): hide missing tool result text 2026-06-17 08:59:02 +08:00
Marcus Castro
8686f04699 refactor(whatsapp): migrate admission object callers (#93787) 2026-06-17 08:58:49 +08:00
Vincent Koc
3001ec4381 refactor(agents): hide session repair fallback text 2026-06-17 08:57:23 +08:00
Vincent Koc
68ef80116f refactor(agents): hide timeout phase predicate 2026-06-17 08:55:48 +08:00
Vincent Koc
dcbea62351 fix(release): keep cross-os artifact names local 2026-06-17 02:53:00 +02:00
Vincent Koc
479e9d94b8 refactor(agents): hide pty paste markers 2026-06-17 08:52:26 +08:00
Vincent Koc
4fef350f8e refactor(agents): narrow transcript policy mode type 2026-06-17 08:50:25 +08:00
Vincent Koc
c91cbce77c refactor(agents): narrow shell snapshot options type 2026-06-17 08:48:42 +08:00
Vincent Koc
9bc7dced98 refactor(agents): remove session repair type alias 2026-06-17 08:47:13 +08:00
Vincent Koc
acb24937e7 refactor(agents): narrow session runtime compat exports 2026-06-17 08:45:32 +08:00
Vincent Koc
2800e8ecb6 fix(release): reject unsafe pack tarball names 2026-06-17 02:42:56 +02:00
Vincent Koc
c40e904c1b refactor(agents): narrow internal event constants 2026-06-17 08:42:31 +08:00
Vincent Koc
10f3e52be0 refactor(agents): narrow deepseek filter type 2026-06-17 08:40:58 +08:00
Vincent Koc
e7e686db2d refactor(agents): narrow tool schema helper exports 2026-06-17 08:39:06 +08:00
liuhao1024
d2279591bf fix(plugins): treat refreshable catalogs as requiring runtime discovery (#93786)
Treat refreshable manifest catalog rows as non-authoritative and load the owning plugin for runtime/cache-backed discovery. Adds focused regression coverage for entries-only and full discovery paths.
2026-06-17 08:38:34 +08:00
Vincent Koc
5c74fde912 fix(release): keep plugin pack filenames local 2026-06-17 02:38:11 +02:00
Vincent Koc
dc384393fc refactor(agents): narrow terminal outcome helpers 2026-06-17 08:36:58 +08:00
Vincent Koc
4e78776a5c fix(ui): refresh realtime talk i18n baseline 2026-06-17 02:35:32 +02:00
Vincent Koc
e62b0122e7 refactor(agents): narrow embedded runner helper exports 2026-06-17 08:34:37 +08:00
Vincent Koc
d1ea170c9b refactor(agents): narrow runtime guard exports 2026-06-17 08:32:26 +08:00
Vincent Koc
916616502f refactor(agents): narrow model helper exports 2026-06-17 08:30:12 +08:00
Vincent Koc
76cd61a903 refactor(agents): narrow internal helper exports 2026-06-17 08:28:13 +08:00
OfflynAI
8432d7d624 fix(webchat): skip textarea resize during IME composition to eliminate typing lag (#93779)
Merged via squash.

Prepared head SHA: 820e10fa49
Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 08:27:21 +08:00
Vincent Koc
2ee4b523b4 refactor(agents): trim unused model helper exports 2026-06-17 08:23:32 +08:00
Vincent Koc
b67775f7fe fix(release): keep package output names local 2026-06-17 02:21:12 +02:00
Patrick Erichsen
5b9a3d05b6 docs: list all ClawHub docs in sidebar 2026-06-16 17:19:12 -07:00
Vincent Koc
06ddc85857 docs(release): require stable main closeout 2026-06-17 08:18:00 +08:00
Vincent Koc
f3f8ca3d92 fix(release): reject loose Docker package timeouts 2026-06-17 02:10:54 +02:00
Vincent Koc
f684527085 fix(release): verify complete contribution coverage 2026-06-17 08:08:31 +08:00
Vincent Koc
e17297f7dc fix(release): reject loose trusted package ports 2026-06-17 01:56:40 +02:00
Eldar Shlomi
7c97c6da9b fix(agents): use neutral billing copy for subscription auth (#93763)
* fix(agents): neutral billing-error copy for OAuth/subscription auth

Fixes #80877

AI-assisted (Claude Code).

* fix(agents): preserve subscription billing guidance

* test(agents): use active auth store in prompt failover

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 07:55:32 +08:00
Vincent Koc
f7d96c9301 docs(changelog): account for 2026.6.8 contributions 2026-06-17 07:48:04 +08:00
Vincent Koc
00d2452fac chore(release): refresh npm shrinkwrap versions 2026-06-17 07:32:37 +08:00
Vincent Koc
cfb27e6437 fix(ci): align plugin SDK surface budget 2026-06-17 07:28:26 +08:00
Vincent Koc
6774e7f259 chore(release): sync main to 2026.6.8 2026-06-17 07:25:30 +08:00
ragesaq
f94a2506d2 feat(context-engine): pass runtime settings into lifecycle (#88750)
Merged via squash.

Prepared head SHA: 9a19334ee5
Co-authored-by: ragesaq <11304287+ragesaq@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-16 16:23:19 -07:00
Vincent Koc
8db66b416b fix(release): reject unsafe Sparkle build floors 2026-06-17 00:55:54 +02:00
Andy Ye
2b92fbc2ee fix(ui): scope Skill Workshop proposals to selected agent (#93773)
* fix(ui): scope skill workshop proposals to selected agent

* fix(ui): scope Skill Workshop proposals by agent

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 06:55:23 +08:00
liuhao1024
7b659543e1 fix(feishu): recover CJK filenames from JSON file_name field (fixes #81103) (#93772)
* fix(feishu): recover CJK filenames from JSON file_name field

Apply recoverUtf8FileNameFromLatin1Header to JSON-derived filenames in
extractFeishuDownloadMetadata, matching the behavior already present for
Content-Disposition headers in decodeDispositionFileName.

Fixes #81103

* fix(feishu): recover inbound CJK filenames

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 06:52:20 +08:00
DrHack1
4f860bfab0 fix(reasoning-tags): strip MiniMax mm: namespaced reasoning tags (#93767)
* fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags

MiniMax M3 (e.g. via Fireworks) emits its chain-of-thought inline in the
content stream wrapped in `<mm:think>…</mm:think>` rather than in a separate
`reasoning_content` field. The reasoning-tag stripper only recognized the
`antml:` namespace, so `mm:`-namespaced tags slipped through QUICK_TAG_RE and
leaked the model's hidden reasoning into visible chat output.

Accept the `mm:` prefix alongside `antml:` in the shared sanitizer
(reasoning-tags.ts) and in the Telegram reasoning-lane coordinator's tag regex
and prefix list. Adds unit tests covering mm: think/thinking/thought blocks,
truncated-open orphan close recovery, and code-fence preservation.

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

* fix(reasoning): handle MiniMax tags in streams

---------

Co-authored-by: DrHack1 <DrHack1@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 06:46:37 +08:00
Vincent Koc
411e79d558 fix(qa): keep kitchen sink sampling scoped 2026-06-17 00:14:11 +02:00
Vincent Koc
7d4001c855 fix(sdk): raise plugin SDK usage heap 2026-06-16 23:59:29 +02:00
Vincent Koc
2caf92a5b7 fix(qa): ignore unsafe Ubuntu VM fallbacks 2026-06-16 23:55:59 +02:00
Vincent Koc
4747e949c7 fix(ci): reject unsafe boundary shard specs 2026-06-16 23:46:37 +02:00
Vincent Koc
5a251bc54c fix(qa): require exact benchmark RSS samples 2026-06-16 23:42:08 +02:00
Vincent Koc
6ede75dbeb fix(qa): reject malformed kitchen sink process samples 2026-06-16 23:33:28 +02:00
Vincent Koc
3576d1e967 fix(testbox): reject unsafe Crabbox version tuples 2026-06-16 23:12:17 +02:00
liuhao1024
003d3100c3 feat(inbound-meta): expose per-turn source modality (#93754)
* feat(inbound-meta): expose message_type in trusted inbound metadata (fixes #50482)

Add resolveInboundMessageType() that extracts the media type prefix
(e.g. 'audio' from 'audio/ogg') from MediaType or MediaTypes fields.
Expose it as message_type in the inbound metadata JSON so agents can
distinguish voice messages from typed text for turn-completion heuristics.

* fix(inbound-meta): preserve per-turn source modality

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 05:10:12 +08:00
liuhao1024
94e6255666 feat(memory): apply outputDimensionality truncation to local GGUF embeddings (fixes #58765) (#93758)
* feat(memory): apply outputDimensionality truncation to local GGUF embeddings

The outputDimensionality config field was passed through to the local
embedding provider but never applied. Local GGUF models (e.g.
Qwen3-Embedding-0.6B) always returned their full dimension vector.

Apply slice(0, N) after normalization so MRL-capable models can benefit
from dimension truncation — matching the behavior already supported by
Gemini embedding-2 and OpenAI providers.

Fixes #58765

* fix(memory): preserve local embedding dimensions through worker

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 05:05:49 +08:00
Vincent Koc
5af44a7616 fix(mobile): reject impossible release pins 2026-06-16 23:03:26 +02:00
Josh Lehman
00a75db428 refactor: route transcript writers through session seam (#89123)
* clawdbot-d08: route transcript writers through accessor seam

* fix: refresh transcript writer seam proofs

* refactor: add transcript turn writer operation

* fix: preserve transcript writer store targeting

* fix: preserve transcript append lock ordering

* refactor: guard transcript turn session rebound

* clawdbot-d02.1.9.1.35: route transcript rewrites through runtime scope

* fix: preserve transcript event append return type

* fix: publish transcript turn owned entries
2026-06-16 13:59:48 -07:00
Gautam Kumar
01d3812ea2 fix: correct tautological uppercase check in tool description summarizer (#93753)
The isToolDocBlockStart function checked normalized === normalized.toUpperCase()
but normalized is already uppercased from line 24, making the condition always true.
This caused mixed-case lines ending with ':' to be incorrectly detected as doc block
starts, truncating tool descriptions unnecessarily.

Compare the original line instead to correctly detect all-uppercase headings.

Co-authored-by: Gautam Kumar <gautamkumarofficial@users.noreply.github.com>
2026-06-17 04:55:25 +08:00
Yuval Dinodia
bc4b1b018a fix(compaction): ignore stale persisted totalTokens in preflight gate (#93749) 2026-06-17 04:47:45 +08:00
Sash Zats
c5b79e3b7a fix(ios): fix quick setup sheet layout design (#93751)
* fix(ios): simplify quick setup sheet layout

* fix(ios): restore quick setup card chrome
2026-06-17 04:45:24 +08:00
Vincent Koc
2a6c0ab5bf fix(release): reject loose upgrade recipe baselines 2026-06-16 22:44:22 +02:00
Vincent Koc
c38bbe751f fix(release): share upgrade baseline version parsing 2026-06-16 22:35:11 +02:00
Vincent Koc
68363368be fix(release): reject unsafe release version numbers 2026-06-16 22:27:20 +02:00
shushushu
d211d49011 fix(ui): populate realtime talk provider and transport options from talk.catalog (#93746)
* fix(ui): populate realtime talk provider and transport options from talk.catalog

* test(ui): cover realtime talk catalog options

* fix(ui): keep realtime talk catalog selections valid

* fix(ui): honor realtime browser session support

* fix(ui): preserve realtime provider fallback options

* fix(ui): clear stale realtime talk catalogs

* fix(ui): refresh realtime talk catalog on reconnect

* fix(ui): reconcile realtime talk catalog selections

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 04:27:05 +08:00
Vincent Koc
52908649d9 fix(release): reject invalid Sparkle prerelease lanes 2026-06-16 22:22:19 +02:00
Vincent Koc
331cc6a8c1 fix(qa): reject loose gateway RSS samples 2026-06-16 22:16:30 +02:00
Vincent Koc
ee9a1f7eac fix(release): reject loose Android version codes 2026-06-16 22:06:32 +02:00
Vincent Koc
2c7c6feb99 fix(qa): reject loose cron cleanup probe pids 2026-06-16 22:01:44 +02:00
Harjoth Khara
a3f3b043c9 fix(usage): reject invalid explicit dates in usage RPC date parsing (#93745)
* fix(usage): reject invalid explicit dates in usage RPC date parsing

usage.cost and sessions.usage accepted shape-valid but impossible dates such as 2026-02-30: parseDateParts validated only the YYYY-MM-DD regex, so Date.* silently rolled them over (2026-02-30 -> 2026-03-02) and the RPC returned cost/usage for the wrong day. Out-of-range parts now fail a UTC round-trip check, and an explicitly provided unparseable date (bad format or impossible calendar date) returns INVALID_REQUEST instead of silently falling back to the default range. Absent/valid dates are unchanged.

[AI-assisted]

* fix(usage): reject non-string explicit dates

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 03:58:46 +08:00
Vincent Koc
edff873d31 fix(qa): reject loose tasklist RSS samples 2026-06-16 21:55:20 +02:00
Vincent Koc
f5194c4647 fix(test): reject loose Vitest wrapper limits 2026-06-16 21:50:19 +02:00
Andy Ye
357462deef fix(sessions): release retained locks after takeover (#93740) 2026-06-17 03:49:59 +08:00
Gio Della-Libera
e58719503e fix: break plugin registry type import cycle (#93738) 2026-06-17 03:49:46 +08:00
Yuval Dinodia
fa65b3270e fix(agents): preserve kept-tail prompt during compaction transcript rotation (#93732)
Duplicate user-message detection ran over the full branch, so when a prompt
was re-sent within the 60s window its earlier copy in the summarized prefix
and the later copy in the kept tail were both removed: the summarized copy via
summarizedBranchIds and the kept copy as a duplicate. With
truncateAfterCompaction enabled the prompt then vanished from the successor
transcript entirely. Restrict dedup to the kept region so the first surviving
copy is preserved.
2026-06-17 03:49:19 +08:00
Vincent Koc
642ae61828 fix(qa): reject loose Windows sampler metrics 2026-06-16 21:43:27 +02:00
Vincent Koc
b816dfbb9f fix(qa): reject malformed kitchen-sink CPU samples 2026-06-16 21:36:59 +02:00
lsr911
583b7195b4 fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting (#93719)
* fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting

sessions.list was O(rows) slow under concurrent agent/cron load because
each row read a process-global active plugin-registry workspace dir
that was mutated by other turns between rows. The per-row memo key
changed every time, so loadPluginMetadataSnapshot scanned fresh
(~100ms per row).

Fix:
1. Add AsyncLocalStorage-based workspace dir pinning to
   runtime-workspace-state.ts — withPinnedActivePluginRegistryWorkspaceDir()
   snapshots the current workspace dir for the duration of a callback.
2. Wrap listSessionsFromStoreAsync body in the pin so all per-row
   metadata lookups use a stable memo key.

Fixes #90814

* test(plugins): cover request-scoped workspace pins

* fix(plugins): pin canonical runtime workspace reads

* fix(plugins): preserve workspace pins across reloads

---------

Co-authored-by: lsr911 <lsr911@github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 03:33:46 +08:00
zhang-guiping
f0b5d78ff9 fix(ollama): preserve configured API during discovery (#93729)
* fix(ollama): preserve configured API during discovery

* fix(ollama): keep compatible discovery base URL

* fix(ollama): route compatible APIs through configured transport

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 03:29:46 +08:00
Peter Lee
f3982d6442 fix(discord): propagate timeout through channel capabilities diagnostics (#93716)
* fix(discord): propagate timeout through channel capabilities diagnostics

* fix(discord): fix type error in capabilities timeout test

* fix(discord): cancel timed-out capability diagnostics

* fix(discord): abort in-flight capability requests

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 03:27:50 +08:00
Vincent Koc
e8022eb4a5 fix(qa): reject loose OpenWebUI probe statuses 2026-06-16 21:24:09 +02:00
Vincent Koc
49cc82e547 fix(qa): reject loose docker stats ceilings 2026-06-16 21:20:59 +02:00
Vincent Koc
fd166a5318 fix(qa): reject loose code-mode live task limits 2026-06-16 21:15:55 +02:00
Peter Lee
319e41d0c5 fix(typing): start typing on reasoning deltas in thinking mode before visible text (#93726) 2026-06-17 03:09:25 +08:00
Marko Milosevic
c6b1921a91 fix(usage): prune stale usage cache temp files (#93725)
Co-authored-by: markoub <2418548+markoub@users.noreply.github.com>
2026-06-17 03:07:09 +08:00
Vincent Koc
6fa9ea08ea fix(qa): reject loose upgrade survivor baseline counts 2026-06-16 21:02:53 +02:00
Vincent Koc
ef5d6a66bd fix(qa): reject loose bundled plugin runtime indexes 2026-06-16 20:58:23 +02:00
Shakker
920e6a8eec chore: set version 2026.6.9 2026-06-16 19:54:07 +01:00
lsr911
4aba273939 fix: scope assistant avatar override to agent ID (#93712)
* fix: scope assistant avatar override to agent ID

The local assistant avatar override was stored globally in
localStorage without an agentId, causing the same avatar to
apply to all agents. Setting an avatar for agent A would
overwrite the avatar for agent B.

Fix: include agentId when saving the local avatar override,
and filter by agentId when loading. An override saved for one
agent no longer bleeds into other agents.

Fixes #90890

* fix(ui): persist assistant avatars per agent

* fix(ui): satisfy scoped avatar checks

---------

Co-authored-by: lsr911 <lsr911@github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 02:53:35 +08:00
Vincent Koc
ff5d6571f2 test(qqbot): avoid bare temp dirs in media path coverage 2026-06-16 20:44:48 +02:00
keshavbotagent
257d540297 Keep command text in progress drafts (#93711)
* Keep command text in progress drafts

* test(channels): align successful progress drafts

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 02:41:59 +08:00
Vincent Koc
ae9ae560e9 fix(qa): reject loose ClickClack wait timeouts 2026-06-16 20:41:12 +02:00
Vincent Koc
ae99ce729a fix(qa): reject loose mock OpenAI ports 2026-06-16 20:37:36 +02:00
Marko Milosevic
b9e193ce22 test(macos): cover root command dispatch (#93705)
* test(macos): cover root command dispatch

* chore(macos): format root command coverage

---------

Co-authored-by: markoub <2418548+markoub@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 02:36:33 +08:00
lsr911
617c9d4b7f fix: isolate async model resolution mock from sync mock in test (#93714)
The 'can preserve asynchronous provider model discovery' test was
flaky because resolveModelAsyncMock in beforeEach delegates to
resolveModelMock. When useAsyncModelResolution=true, the test
asserted resolveModelMock was not called, but the delegation
caused it to be called, failing CI on two lanes.

Fix: use a standalone vi.fn() for the async resolver in this
test, and explicitly reset resolveModelMock before the assertion
to guard against mock state leakage from prior tests.

Fixes #92117

Co-authored-by: lsr911 <lsr911@github.com>
2026-06-17 02:29:29 +08:00
Vincent Koc
76658cd159 fix(ci): keep ci workflow edits off fast-only routing 2026-06-16 20:28:23 +02:00
dwc1997
3ad3cc61b8 fix(minimax): check base_resp envelope errors in TTS provider (#93688)
MiniMax TTS API returns HTTP 200 even on quota/billing errors, with the
error encoded in base_resp.status_code. Without this check, placeholder
audio returned alongside the error is silently accepted, preventing the
TTS dispatcher from falling back to a configured secondary provider.

This follows the same pattern used by all other MiniMax providers:
- image-generation-provider.ts
- video-generation-provider.ts
- music-generation-provider.ts
- minimax-web-search-provider.runtime.ts

Fixes #76904

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 02:25:46 +08:00
Vincent Koc
3b7729779a fix(ios): reject malformed node e2e wait seconds 2026-06-16 20:20:46 +02:00
liuhao1024
17bdd3375f fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099) (#93679)
* fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099)

- Add escapeAttr() helper to sanitize quotes and angle brackets in XML attribute values
- Add extractExternalAdReplyMetadata() to extract title, sourceUrl, body from contextInfo.externalAdReply
- Distinguish GIFs from videos using videoMessage.gifPlayback flag (media:gif vs media:video)
- Enrich image and video placeholders with externalAdReply metadata when available
- Add 5 test cases covering GIF detection, metadata extraction, attribute escaping, and empty fields

* fix(whatsapp): keep GIF metadata in untrusted context

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 02:12:10 +08:00
ZengWen-DT
cfdcd5cdfd fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root (#92947)
QQBot is the only channel that root-sandboxes outbound local files. Its three
gate sites (resolveOutboundMediaPath, the voice send re-check, and
structured-payload validation) only trusted the QQ Bot media storage roots, so
framework-generated scratch media written under OpenClaw's hardened temp root
(e.g. cron auto-TTS voice files from speech-core) was rejected. The send then
returned a no-identity error, the message was silently lost, yet cron still
recorded it as delivered.

Add one shared resolver (resolveTrustedOutboundMediaPath) that also trusts the
preferred OpenClaw temp root — already a sanctioned media root in core
(buildMediaLocalRoots) — and route all three gates through it so the trust set
agrees everywhere. Fixes #92816.

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 02:11:52 +08:00
Vincent Koc
875669e38e fix(ci): keep approval type contracts off runtime imports 2026-06-16 20:08:49 +02:00
Vincent Koc
8d159e1ff8 fix(qa): reject loose Parallels host ports 2026-06-16 20:08:49 +02:00
Goutam Adwant
2c286c3465 fix(google-meet): declare realtime provider secret inputs (#93677)
* fix(google-meet): declare realtime provider secret inputs

* test(secrets): cover Google Meet installed manifest

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 01:58:37 +08:00
Vincent Koc
7bf821a2ee fix(qa): match Windows RPC sampling ports exactly 2026-06-16 19:48:32 +02:00
Peter Steinberger
070685f765 chore(release): update appcast for 2026.6.8 (#93722)
Merged via squash.

Prepared head SHA: 8647435153
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 01:37:43 +08:00
Vincent Koc
33862206b4 fix(qa): reject invalid qa lab ports 2026-06-16 19:35:48 +02:00
Alix-007
e77fa3aeba fix(openai-completions): guard string assistant content in transform + tool-history (#93681)
When an assistant message's `content` is a raw string at runtime (JSONL
transcript replay passes it through even though the type declares an array),
the OpenAI-compatible completions path crashes:

- `transformMessages` called `assistantMsg.content.flatMap(...)` ->
  `TypeError: ... .flatMap is not a function` (first crash, always hit).
- Two `hasToolHistory` helpers (`openai-transport-stream.ts` and
  `openai-completions.ts`) called `content.some(...)` -> `TypeError: ...
  .some is not a function` (siblings, surface once the flatMap crash is fixed).

Normalize a string assistant content to an equivalent single text block
before transforming (matching the string->text handling already used in
anthropic-payload-policy.ts), and `Array.isArray`-guard both `hasToolHistory`
helpers so a string assistant simply does not count toward tool history.

Verified end-to-end through the real `buildOpenAICompletionsParams` and
`streamOpenAICompletions` entry points: before the fix a string-content
assistant followed by a toolResult throws TypeError; after the fix params are
produced correctly (string preserved as text, tool history detected). Normal
array content is unaffected.
2026-06-17 01:32:46 +08:00
Vincent Koc
c1df7aa08b fix(context-engine): avoid turn-maintenance lane livelock (#93727)
* fix(context-engine): resolve deferred turn-maintenance livelock

Co-authored-by: Vishnu <268122714+baghvn@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-451-autonomous-terminal-gap (1)

Co-authored-by: Vishnu <268122714+baghvn@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-451-autonomous-terminal-gap (1)

Co-authored-by: Vishnu <268122714+baghvn@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-451-autonomous-terminal-gap (1)

Co-authored-by: Vishnu <268122714+baghvn@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vishnu <268122714+baghvn@users.noreply.github.com>
2026-06-17 01:30:09 +08:00
litang9
d1e20d2f29 fix(gateway): surface codex app-server returned failures (#93665)
* fix(gateway): surface codex app-server returned failures

* fix(auto-reply): retain codex app-server failures

* fix(agents): mark codex completion timeouts terminal

---------

Co-authored-by: Alex Tang <tangli1987118@hotmail.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 01:20:34 +08:00
mushuiyu_xydt
1469441ff4 fix(gateway): send approval route notices with write scope (#93656)
* fix(gateway): send approval route notices with write scope

* fix(gateway): avoid approval runtime import cycle

* fix(approvals): preserve scoped runtime requests

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 01:19:19 +08:00
Ayaan Zaidi
42dcf7075f Fix Telegram rich progress detail updates (#93698)
Merged via squash.

Prepared head SHA: 77da499825
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-06-16 22:48:12 +05:30
liuhao1024
0278b59d0e fix(respawn): rewrite pnpm versioned entry paths to stable wrapper (fixes #52313) (#93671)
* fix(respawn): rewrite pnpm versioned entry paths to stable wrapper

During self-update the pnpm versioned directory (node_modules/.pnpm/openclaw@<ver>/)
may be removed. If process.argv contains the versioned path, the respawned child
fails to start because the entrypoint no longer exists.

Detect pnpm versioned realpaths in spawnDetachedGatewayProcess and rewrite them
to the stable node_modules/<pkg>/openclaw.mjs wrapper before spawning.

Fixes #52313

* fix(respawn): scope pnpm entry rewrite to openclaw

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 01:15:24 +08:00
joshavant
62503c4b48 android: check version sync for mixed release changes 2026-06-16 19:13:44 +02:00
joshavant
f78235d346 android: include changelog in metadata upload 2026-06-16 19:13:44 +02:00
joshavant
d42e557a66 android: combine play release upload 2026-06-16 19:13:44 +02:00
joshavant
2971775ead android: upload changelog with play build 2026-06-16 19:13:44 +02:00
joshavant
c9a854c217 android: fix release screenshot review blockers 2026-06-16 19:13:44 +02:00
joshavant
f3ab59db58 android: generate release screenshots 2026-06-16 19:13:44 +02:00
joshavant
91fb5d3823 android: add play release upload lane 2026-06-16 19:13:44 +02:00
joshavant
91220cbd31 android: archive release artifacts from pinned version 2026-06-16 19:13:44 +02:00
joshavant
40eec48caf android: add pinned release versioning 2026-06-16 19:13:44 +02:00
ml12580
b836946879 fix(wizard): preserve existing default model during setup auth choice [AI-assisted] (#93658)
* fix(wizard): preserve existing default model during setup auth choice

Without preserveExistingDefaultModel: true, the setup wizard
overwrite the user's configured default model when a new provider
auth is selected. This causes existing heartbeat turns to silently
consume paid API quota (e.g. Google Gemini) instead of the user's
original model.

The configure.gateway-auth.ts path already passes this flag; the
setup wizard path was missing it.

Fixes #64129

* fix(wizard): add type assertion for preserveExistingDefaultModel test
2026-06-17 01:13:33 +08:00
ZengWen-DT
6470bb7625 fix(heartbeat): bootstrap plugin session targets (#93630)
* fix(heartbeat): bootstrap plugin session targets

* fix(heartbeat): reuse bootstrapped route plugin

* fix(heartbeat): preserve active external route plugins

* fix(heartbeat): carry prepared plugin through routing

* fix(heartbeat): canonicalize with prepared route plugin

* fix(heartbeat): preserve explicit route account context

* fix(heartbeat): enforce prepared plugin route policy

* fix(outbound): partition prepared plugin target cache

* test(cron): expect prepared delivery plugin

* test(outbound): use complete plugin fixtures

* fix(outbound): bootstrap direct metadata shells

* fix(outbound): scope external runtime activation

* test(outbound): model activated direct plugins

* fix(heartbeat): keep route policy activation-aware

* fix(heartbeat): preserve prepared plugin policy

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 01:02:49 +08:00
Vincent Koc
617f97d4b9 fix(plugin-sdk): refresh API baseline hash 2026-06-16 18:34:45 +02:00
Vincent Koc
3d05da9a54 fix(telegram): preserve streamed text during media normalization 2026-06-16 18:19:04 +02:00
clawsweeper[bot]
5ce413a2c7 fix(i18n): retain Codex error tails in logs (#93687)
Summary:
- This PR changes the docs i18n Codex command-output preview to keep a short head plus retained tail, and adds Go unit coverage for stdout and stderr tails.
- PR surface: Other +20. Total +20 across 2 files.
- Reproducibility: yes. Source inspection of current main and `v2026.6.6` shows long output is truncated to the prefix only, and the PR's focused tests model the stdout/stderr tail cases that lose final API details.

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

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

Prepared head SHA: b510b598c6
Review: https://github.com/openclaw/openclaw/pull/93687#issuecomment-4720840859

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Mason Huang <8814856+hxy91819@users.noreply.github.com>
Approved-by: hxy91819
2026-06-16 16:14:12 +00:00
Alix-007
3630ce6cbb fix(agents): return string assistant content in getLastAssistantText (#93646)
* fix(agents): handle string assistant content in getLastAssistantText

PR #93456 added an `if (!Array.isArray(message.content)) return false` guard
to hasAssistantToolCallArguments, acknowledging that a persisted/legacy
assistant message can carry a string `content` at runtime even though the
type is declared as an array. buildSessionContext pushes such entries through
unchanged, so the string can reach agent.state.messages.

getLastAssistantText() still assumed an array: iterating a string `content`
yields individual characters, none of which has `type === "text"`, so the
assistant's text was silently dropped and the function returned undefined.

Mirror extractTextContent(): when `content` is a string, treat it as the text
itself; otherwise iterate the content blocks as before. The aborted/empty
check is left untouched because `.length === 0` is already correct for both an
empty array and an empty string.

* fix(agents): safely read persisted assistant text

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 23:57:28 +08:00
Vincent Koc
64785823d0 fix(gateway): ignore stale sudo scope for root user services (#93693)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 23:56:12 +08:00
Vincent Koc
6e3ebaccf0 fix(telegram): dispatch MEDIA directives as attachments (#93690)
* fix(telegram): deliver MEDIA directive replies as attachments

* fix(clownfish): address review for gitcrawl-167-autonomous-terminal-gap (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 23:52:59 +08:00
liuhao1024
a67ae8137d fix(reply): preserve unsent text-only finals after block pipeline streamed partial content (fixes #81078) (#93629)
When the block reply pipeline streamed partial content, buildReplyPayloads()
unconditionally dropped all text-only final payloads. This suppressed the
complete final reply when the pipeline only streamed a partial block and
never sent the exact final text.

The fix checks hasSentPayload() for text-only payloads too, preserving
unsent finals instead of dropping them unconditionally.
2026-06-16 23:49:12 +08:00
pick-cat
a6b348a307 fix(control-ui): copy code blocks over plain HTTP via clipboard fallback (#93666)
The async Clipboard API is only available in secure contexts (HTTPS or
localhost). On plain-HTTP deployments navigator.clipboard is undefined, so the
code block copy button threw synchronously and silently failed. Add a shared
copyToClipboard helper that guards the secure-context path and falls back to the
legacy execCommand copy, reuse it for the code block button and the copy-as-
markdown affordance, and cover it with a unit test plus a real-browser e2e that
simulates the non-secure context.

Fixes #93628

Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
2026-06-16 23:47:03 +08:00
Vincent Koc
f285a0c4c4 fix(ci): fail unusable Windows testbox phone-home 2026-06-16 17:24:18 +02:00
Vincent Koc
05584427a8 fix(deps): update Hono security pin
Update the global Hono override and published shrinkwraps to 4.12.25 so release packages avoid the current high-severity CORS advisory.
2026-06-16 23:12:39 +08:00
Mason Huang
f046d7aa23 fix(status): ignore stale context after model switch (#93306)
Summary:
- The PR changes `/status` context-window selection to ignore stale runtime snapshots after manual model switches while preserving fallback/runtime-alias context windows.
- PR surface: Source +6, Tests +128. Total +134 across 2 files.
- Reproducibility: yes. source-reproducible: current main trusts explicit runtime context before checking fall ... fer. I did not run a local failing repro, but the PR fixture models the stale prior-runtime state directly.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(status): make context fixtures type-correct

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

Prepared head SHA: f14fda4279
Review: https://github.com/openclaw/openclaw/pull/93306#issuecomment-4708596208

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Approved-by: hxy91819
2026-06-16 15:05:51 +00:00
Vincent Koc
de1d329e31 fix(plugins): allow Dreaming sidecar through restrictive memory allowlists (#93678)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: pradeep7127 <21005375+pradeep7127@users.noreply.github.com>
2026-06-16 23:02:15 +08:00
Vincent Koc
75cdf22152 fix(browser): accept top-level act fields with nested requests (#93674)
* fix(browser): accept top-level act fields with nested requests

Co-authored-by: Capivariano <11271294+angelusbr@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-416-autonomous-terminal-gap (1)

Co-authored-by: Capivariano <11271294+angelusbr@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Capivariano <11271294+angelusbr@users.noreply.github.com>
2026-06-16 22:50:53 +08:00
Vincent Koc
acc375ff75 fix(commands): preserve multiline slash skill args (#93672)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Blind Dev <264741654+web3blind@users.noreply.github.com>
2026-06-16 22:47:15 +08:00
Vincent Koc
e48222175f fix(browser): recover stale managed Chrome CDP listener (#93670)
* fix(browser): recover stale managed Chrome CDP listener

Co-authored-by: Rohit <76606932+rohitjavvadi@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-387-autonomous-terminal-gap (1)

Co-authored-by: Rohit <76606932+rohitjavvadi@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Rohit <76606932+rohitjavvadi@users.noreply.github.com>
2026-06-16 22:45:21 +08:00
Vincent Koc
1fc04ac6e3 fix(ios): satisfy watch screenshot SwiftFormat 2026-06-16 16:24:32 +02:00
Vincent Koc
5939a2ac9f fix(clawdock): open dashboard on published port without starting deps (#93663)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Derek Homan <205598+dhoman@users.noreply.github.com>
2026-06-16 22:24:00 +08:00
Vincent Koc
6656c71c7a fix(discord): protect mention aliases in code fences (#93662)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Rohit <76606932+rohitjavvadi@users.noreply.github.com>
2026-06-16 22:22:02 +08:00
github-actions[bot]
8c7690b256 chore(ui): refresh fa control ui locale 2026-06-16 14:18:52 +00:00
github-actions[bot]
7c314e1504 chore(ui): refresh nl control ui locale 2026-06-16 14:18:27 +00:00
github-actions[bot]
6ab41d50d4 chore(ui): refresh th control ui locale 2026-06-16 14:17:41 +00:00
github-actions[bot]
f95ca1de26 chore(ui): refresh vi control ui locale 2026-06-16 14:17:28 +00:00
github-actions[bot]
cb811d4650 chore(ui): refresh pl control ui locale 2026-06-16 14:17:25 +00:00
github-actions[bot]
45343f5d64 chore(ui): refresh id control ui locale 2026-06-16 14:17:01 +00:00
github-actions[bot]
ab71827cf3 chore(ui): refresh uk control ui locale 2026-06-16 14:16:05 +00:00
github-actions[bot]
7e46326d21 chore(ui): refresh tr control ui locale 2026-06-16 14:15:59 +00:00
github-actions[bot]
c861730047 chore(ui): refresh it control ui locale 2026-06-16 14:15:55 +00:00
github-actions[bot]
5dee1eefb7 chore(ui): refresh ar control ui locale 2026-06-16 14:15:45 +00:00
github-actions[bot]
8fc5911e21 chore(ui): refresh fr control ui locale 2026-06-16 14:14:42 +00:00
github-actions[bot]
d344dcbd91 chore(ui): refresh ko control ui locale 2026-06-16 14:14:38 +00:00
github-actions[bot]
4e3d2ff79b chore(ui): refresh es control ui locale 2026-06-16 14:14:27 +00:00
github-actions[bot]
3e2e3dfa92 chore(ui): refresh ja-JP control ui locale 2026-06-16 14:14:22 +00:00
github-actions[bot]
f11bf1ed42 chore(ui): refresh pt-BR control ui locale 2026-06-16 14:13:29 +00:00
github-actions[bot]
47ce7bc581 chore(ui): refresh zh-TW control ui locale 2026-06-16 14:13:20 +00:00
github-actions[bot]
e1770b041c chore(ui): refresh zh-CN control ui locale 2026-06-16 14:13:14 +00:00
github-actions[bot]
d3c86f96af chore(ui): refresh de control ui locale 2026-06-16 14:13:09 +00:00
Vincent Koc
ea6704319a fix(ui): localize Talk error dismiss label 2026-06-16 16:08:00 +02:00
Vincent Koc
61b104cf73 fix(codex): expose remote node exec as a Codex dynamic tool (#93654)
* fix(codex): expose remote node exec as a Codex dynamic tool

* fix(clownfish): address review for gitcrawl-170-autonomous-terminal-gap (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 22:06:29 +08:00
Vincent Koc
a9df801902 fix(skill-workshop): skip helper sessions during auto-capture (#93653)
* fix(skill-workshop): skip helper sessions during auto-capture

Co-authored-by: zhang-guiping <275915537+zhangguiping-xydt@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-164-autonomous-terminal-gap (1)

Co-authored-by: zhang-guiping <275915537+zhangguiping-xydt@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: zhang-guiping <275915537+zhangguiping-xydt@users.noreply.github.com>
2026-06-16 22:05:23 +08:00
joshavant
6b3e23aba7 chore(ios): capture watch now face screenshot 2026-06-16 15:59:02 +02:00
joshavant
4542d3914c chore(ios): remove review information metadata 2026-06-16 15:59:02 +02:00
joshavant
d24a589f1c chore(ios): rename signing repository reference 2026-06-16 15:59:02 +02:00
joshavant
0af07bb378 chore(ios): migrate release signing to fastlane match 2026-06-16 15:59:02 +02:00
Vincent Koc
df8ceb5267 fix(update): avoid per-Node npm prefixes during self-update (#93650)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Soham Patankar <102520430+yaanfpv@users.noreply.github.com>
2026-06-16 21:54:08 +08:00
Vincent Koc
32d1ccd71c test(feishu): reset lifecycle monitor state 2026-06-16 15:44:08 +02:00
Vincent Koc
998445ea20 fix(qwen): place DashScope image prompts in user content (#93649)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 21:42:59 +08:00
Vincent Koc
5cebe96667 fix(doctor): archive superseded plugin install index conflicts (#93648)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 21:42:46 +08:00
Colin Johnson
c45c87acca feat(ios): add watch action surface (#93387)
* feat(ios): add watch action surface

* fix: harden watch action surface

* fix: clean watch codegen lint

* fix(ios): scope watch chat commands to gateway

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-16 15:22:16 +02:00
liuhao1024
a4d013a9f3 fix(feishu): filter temporary card-action-c-* IDs from reply target to prevent Invalid open_message_id errors (fixes #56818) (#93618)
Merged via squash.

Prepared head SHA: cca7b25384
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 21:18:05 +08:00
Vincent Koc
9de6a99c8f test(telegram): stabilize spool timeout recovery assertions 2026-06-16 15:11:45 +02:00
zhang-guiping
fa0116b0a0 fix #93044: control-ui webchat double-renders agent replies when dmScope=main (#93298)
Merged via squash.

Prepared head SHA: b7ab9ba0fa
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 21:10:00 +08:00
Vincent Koc
49572863d3 fix(telegram): preserve live spool claim owners 2026-06-16 15:03:11 +02:00
Dave Morin
c48b36a255 Keep key-free web search providers opt-in (#93616)
Merged via squash.

Prepared head SHA: 5de02da038
Co-authored-by: davemorin <78139+davemorin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 21:02:07 +08:00
Nik
11b6c01198 fix(telegram): recover lone active spooled handler on timeout (#84158) (#93615)
Merged via squash.

Prepared head SHA: 2da361a345
Co-authored-by: 0xghost42 <151941421+0xghost42@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 20:36:11 +08:00
liuhao1024
aebf0bbd2d fix(gateway): compute sessions.usage aggregate totals from all sessions, not just the limited page (fixes #76496) (#93612)
Merged via squash.

Prepared head SHA: 349b8cd066
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 20:33:10 +08:00
liuhao1024
e567986355 perf(tasks): memoize reconcileInspectableTasks for same-tick calls (fixes #73531) (#93607)
Merged via squash.

Prepared head SHA: f010d09db9
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 20:31:51 +08:00
liuhao1024
c4940a4ff9 fix(ui): clear stale Talk error when session transitions to non-error state (fixes #88176) (#93606)
Merged via squash.

Prepared head SHA: 468b0bd01d
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 20:30:35 +08:00
Vincent Koc
ed16f8fcf0 fix(ci): require billable Anthropic release key 2026-06-16 20:02:03 +08:00
iloveleon19
65805e519d fix(mattermost): keep bare @mention with empty body instead of dropping it (#93242)
Merged via squash.

Prepared head SHA: 7f6d21677b
Co-authored-by: iloveleon19 <37945260+iloveleon19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 19:40:25 +08:00
Vincent Koc
fd61b1b6ee fix(ci): update test helper expectations 2026-06-16 13:35:59 +02:00
Alix-007
9dbc423aa4 fix(whatsapp): bound stalled read-receipt socket operations (#93303)
Merged via squash.

Prepared head SHA: 27752b17a0
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 19:21:12 +08:00
Vincent Koc
35ffbf93b9 fix(ci): prefer Anthropic OAuth in live validation 2026-06-16 19:12:02 +08:00
BitmapAsset
1881a0188b fix(plugins): resolve provider policy surface for plugin-owned CLI backends (#93261)
Merged via squash.

Prepared head SHA: 27ebfc7c4d
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 19:05:12 +08:00
Eldar Shlomi
33bf9874bf fix(telegram): hydrate group reply-chain media into model context [AI-assisted] (#93575)
Merged via squash.

Prepared head SHA: f108f6eae4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:58:31 +08:00
Moeed Ahmed
ecd0d17243 Fix tokenjuice bash results without details (#93269)
Merged via squash.

Prepared head SHA: 33de08d9a1
Co-authored-by: moeedahmed <5780040+moeedahmed@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:53:52 +08:00
Eldar Shlomi
e4f448c74f fix(feishu): suppress log noise for bot_p2p_chat_entered_v1 event [AI-assisted] (#93574)
Merged via squash.

Prepared head SHA: 598af62d5b
Co-authored-by: eldar702 <72104254+eldar702@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:47:31 +08:00
Daniel Morandini
6163425a2d Fix SSH sandbox remote directory args (#93367)
Merged via squash.

Prepared head SHA: 02e3d7eb9f
Co-authored-by: dmorn <10097445+dmorn@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:45:18 +08:00
Yzx
b0a2b65d81 fix(cron): emit isolated model usage diagnostics (#93398)
Merged via squash.

Prepared head SHA: 727c189774
Co-authored-by: 849261680 <53250620+849261680@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:42:01 +08:00
Eldar Shlomi
fcdbef732c fix(acp): keep bridge sessions out of stale ACP classification [AI-assisted] (#93573)
Merged via squash.

Prepared head SHA: f26140a4b4
Co-authored-by: eldar702 <72104254+eldar702@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:39:53 +08:00
Alix-007
a6dd20ae9d fix(skills): preserve ClawHub origin provenance on readback (#93314)
Merged via squash.

Prepared head SHA: 8bd8df1549
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:33:40 +08:00
Vincent Koc
fa33f5bbb8 fix(plugin-sdk): refresh API baseline hash 2026-06-16 12:32:39 +02:00
zhanxingxin1998
a117064697 fix(read): route text decoding through shared Windows codepage fallba… (#93555)
Merged via squash.

Prepared head SHA: ab97624258
Co-authored-by: zhanxingxin1998 <293803800+zhanxingxin1998@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:31:39 +08:00
Vincent Koc
4292f0fe7f refactor(agents): narrow unused helper exports 2026-06-16 18:22:02 +08:00
Vincent Koc
623761e5c5 fix(ci): pin Rocky install smoke platform 2026-06-16 12:21:39 +02:00
Vincent Koc
8415887646 refactor(agents): remove unused helper exports 2026-06-16 18:19:56 +08:00
zhang-guiping
f1b6a60583 Clarify plugin channel config additional-property errors (#93274)
Merged via squash.

Prepared head SHA: dce4a38bbc
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:13:49 +08:00
Vincent Koc
b500a488e4 fix(ci): support Anthropic OAuth release validation 2026-06-16 18:10:49 +08:00
Vincent Koc
645fe838ff fix(ci): align checkout guard timeout 2026-06-16 12:10:23 +02:00
Vincent Koc
4fee348764 refactor(agents): remove unused credential comparator 2026-06-16 18:08:32 +08:00
Vincent Koc
0471275270 refactor(agents): remove unused process registry export 2026-06-16 18:07:18 +08:00
Vincent Koc
203bddcdb7 refactor(agents): drop unused truncation export 2026-06-16 18:06:05 +08:00
Vincent Koc
c6d549c5a7 test(ci): update checkout timeout guard 2026-06-16 18:04:58 +08:00
Alix-007
176572cb35 fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin (#93509)
Merged via squash.

Prepared head SHA: 0dd53d2dac
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:04:47 +08:00
Vincent Koc
55c047e77e fix(ci): relax checkout fetch timeout 2026-06-16 17:51:06 +08:00
1618 changed files with 55672 additions and 19340 deletions

View File

@@ -91,6 +91,32 @@ attribution.
- if any compatibility `removeAfter` is on/before release date, resolve it
or explicitly record the blocker before shipping
10. Validate and ship:
- generate and verify the complete contribution ledger before committing:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--write-ledger
```
- the command fails when any `#NNN` reference in release history or the
rendered release section is absent from the ledger, when reverted work is
presented as shipped, or when an eligible PR author, issue reporter, or
known co-author is missing from that entry's `Thanks @...` credit
- after the GitHub release or prerelease is published, verify every matching
release page against the same source section:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check; the GitHub body must begin with the complete
`## YYYY.M.PATCH` changelog section, including its heading
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`

View File

@@ -0,0 +1,443 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const repo = "openclaw/openclaw";
const excludedHandles = new Set(["openclaw", "clawsweeper", "codex", "steipete"]);
function fail(message) {
throw new Error(message);
}
function parseArgs(argv) {
const options = {
releaseTags: [],
checkGithub: false,
json: false,
writeLedger: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check-github" || arg === "--json" || arg === "--write-ledger") {
options[
arg === "--check-github"
? "checkGithub"
: arg === "--write-ledger"
? "writeLedger"
: "json"
] = true;
continue;
}
if (arg === "--base" || arg === "--target" || arg === "--version" || arg === "--release-tag") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
fail(`missing value for ${arg}`);
}
if (arg === "--release-tag") {
options.releaseTags.push(value);
} else {
options[arg.slice(2)] = value;
}
index += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
for (const name of ["base", "target", "version"]) {
if (!options[name]) {
fail(`--${name} is required`);
}
}
if (options.checkGithub && options.releaseTags.length === 0) {
fail("--check-github requires at least one --release-tag");
}
return options;
}
function run(command, args) {
return execFileSync(command, args, {
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
}
function git(args) {
return run("git", args).trimEnd();
}
function githubApi(args) {
try {
return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
} catch (error) {
if (typeof error.stdout === "string" && error.stdout.trim() !== "") {
return JSON.parse(error.stdout.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
}
throw error;
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isEligibleHandle(handle) {
return Boolean(handle) && !handle.endsWith("[bot]") && !excludedHandles.has(handle.toLowerCase());
}
function sectionFor(changelog, version) {
const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog);
if (!heading || heading.index === undefined) {
fail(`CHANGELOG.md does not contain ## ${version}`);
}
const start = heading.index;
const bodyStart = changelog.indexOf("\n", start) + 1;
const next = /^## /gm;
next.lastIndex = bodyStart;
const nextHeading = next.exec(changelog);
const end = nextHeading?.index ?? changelog.length;
return {
start,
end,
source: changelog.slice(start, end).trimEnd(),
body: changelog.slice(bodyStart, end).trim(),
};
}
function referencesIn(text) {
return [...text.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
}
function appendReferences(references, additions) {
const seen = new Set(references);
for (const number of additions) {
if (!seen.has(number)) {
references.push(number);
seen.add(number);
}
}
}
function sourceCommits(base, target) {
const mergeBase = git(["merge-base", base, target]);
const output = git([
"log",
"--first-parent",
"--reverse",
"--format=%H%x1f%s%x1f%B%x1e",
`${mergeBase}..${target}`,
]);
const commits = new Map();
const revertsByTarget = new Map();
for (const record of output.split("\x1e")) {
if (!record) {
continue;
}
const [rawHash, subject, ...bodyParts] = record.split("\x1f");
const hash = rawHash.trim();
const body = bodyParts.join("\x1f");
const revertedHash = body.match(/This reverts commit ([0-9a-f]{7,40})\./i)?.[1];
const isRevert = subject.startsWith('Revert "') || Boolean(revertedHash);
commits.set(hash, { body, hash, isRevert, revertedHash, subject });
}
for (const commit of commits.values()) {
if (!commit.revertedHash) {
continue;
}
const targetHash = [...commits.keys()].find((candidate) => candidate.startsWith(commit.revertedHash));
if (targetHash) {
const reverts = revertsByTarget.get(targetHash) ?? [];
reverts.push(commit.hash);
revertsByTarget.set(targetHash, reverts);
}
}
const active = new Map();
function isActive(hash) {
if (active.has(hash)) {
return active.get(hash);
}
const cancellingReverts = revertsByTarget.get(hash) ?? [];
const value = !cancellingReverts.some((revertHash) => isActive(revertHash));
active.set(hash, value);
return value;
}
const references = [];
const revertedReferences = new Set();
const coauthorsByReference = new Map();
for (const commit of commits.values()) {
if (commit.isRevert) {
continue;
}
const uniqueReferences = [...new Set(referencesIn(`${commit.subject}\n${commit.body}`))];
if (!isActive(commit.hash)) {
for (const number of uniqueReferences) {
revertedReferences.add(number);
}
continue;
}
appendReferences(references, uniqueReferences);
const coauthors = [...commit.body.matchAll(/<(?:(?:\d+)\+)?([^@<>\s]+)@users\.noreply\.github\.com>/gi)]
.map((match) => match[1])
.filter(isEligibleHandle);
for (const number of uniqueReferences) {
if (coauthors.length > 0) {
const handles = coauthorsByReference.get(number) ?? new Set();
for (const handle of coauthors) {
handles.add(handle);
}
coauthorsByReference.set(number, handles);
}
}
}
return { mergeBase, references, revertedReferences, coauthorsByReference };
}
function graphql(query) {
return githubApi(["graphql", "-f", `query=${query}`]).data;
}
function resolveReferences(numbers) {
const nodes = new Map();
for (let index = 0; index < numbers.length; index += 40) {
const chunk = numbers.slice(index, index + 40);
const fields = chunk
.map(
(number) => `n${number}: repository(owner: "openclaw", name: "openclaw") {
issueOrPullRequest(number: ${number}) {
__typename
... on Issue { number title author { __typename login } }
... on PullRequest { number title author { __typename login } }
}
}`,
)
.join("\n");
const data = graphql(`query { ${fields} }`);
for (const number of chunk) {
const node = data[`n${number}`]?.issueOrPullRequest;
if (node) {
nodes.set(number, node);
}
}
}
return nodes;
}
function resolveCoauthors(handles) {
const resolved = new Map();
const uniqueHandles = [...new Set(handles)];
for (let index = 0; index < uniqueHandles.length; index += 80) {
const chunk = uniqueHandles.slice(index, index + 80);
const fields = chunk
.map(
(handle, offset) =>
`u${index + offset}: user(login: ${JSON.stringify(handle)}) { __typename login }`,
)
.join("\n");
const data = graphql(`query { ${fields} }`);
for (let offset = 0; offset < chunk.length; offset += 1) {
const user = data[`u${index + offset}`];
if (user?.__typename === "User" && isEligibleHandle(user.login)) {
resolved.set(chunk[offset].toLowerCase(), user.login);
}
}
}
return resolved;
}
function thanksFor(node, coauthorHandles) {
const handles = [];
if (node.author?.__typename === "User" && isEligibleHandle(node.author.login)) {
handles.push(node.author.login);
}
for (const handle of coauthorHandles) {
if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) {
handles.push(handle);
}
}
return handles;
}
function ledgerFor(base, target, references, nodes, coauthorsByReference, resolvedCoauthors) {
const missing = references.filter((number) => !nodes.has(number));
if (missing.length > 0) {
fail(`GitHub could not resolve source references: ${missing.map((number) => `#${number}`).join(", ")}`);
}
const entries = references.map((number) => {
const node = nodes.get(number);
const rawCoauthors = coauthorsByReference.get(number) ?? new Set();
const coauthors = [...rawCoauthors]
.map((handle) => resolvedCoauthors.get(handle.toLowerCase()))
.filter(Boolean);
return {
number,
title: node.title.replace(/#(\d+)/g, "issue $1").replace(/\s+/g, " ").trim(),
type: node.__typename,
thanks: thanksFor(node, coauthors),
};
});
const pullRequests = entries.filter((entry) => entry.type === "PullRequest");
const issues = entries.filter((entry) => entry.type === "Issue");
const renderEntry = (entry, issue = false) => {
const attribution = entry.thanks.length > 0 ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` : "";
return `- ${issue ? "Reported: " : ""}${entry.title} (#${entry.number}).${attribution}`;
};
const ledger = [
"### Complete contribution ledger",
"",
`This audited record covers the complete ${base}..${target} history: ${pullRequests.length} PRs and ${issues.length} linked issues. The grouped notes above prioritize user impact; this ledger preserves every contribution reference and eligible human credit.`,
"",
"#### Pull requests",
"",
...pullRequests.map((entry) => renderEntry(entry)),
"",
"#### Linked issues",
"",
...issues.map((entry) => renderEntry(entry, true)),
].join("\n");
return { entries, issues, ledger, pullRequests };
}
function replaceLedger(changelog, section, ledger) {
const beforeLedger = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "").trimEnd();
const replacement = `${beforeLedger}\n\n${ledger}\n`;
return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`;
}
function ledgerChecks(section, entries) {
const errors = [];
if (!section.source.includes("### Highlights")) {
errors.push("missing ### Highlights");
}
if (!section.source.includes("### Changes")) {
errors.push("missing ### Changes");
}
if (!section.source.includes("### Fixes")) {
errors.push("missing ### Fixes");
}
const ledgerStart = section.source.indexOf("### Complete contribution ledger");
if (ledgerStart < 0) {
errors.push("missing ### Complete contribution ledger");
return errors;
}
const ledger = section.source.slice(ledgerStart);
const entryNumbers = new Set(entries.map((entry) => entry.number));
for (const number of new Set(referencesIn(section.source))) {
if (!entryNumbers.has(number)) {
errors.push(`missing ledger entry for #${number}`);
}
}
for (const entry of entries) {
const prefix = entry.type === "Issue" ? "- Reported: " : "- ";
const line = ledger
.split("\n")
.find((candidate) => candidate.startsWith(prefix) && candidate.includes(`(#${entry.number})`));
if (!line) {
errors.push(`missing ledger entry for #${entry.number}`);
continue;
}
for (const handle of entry.thanks) {
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
errors.push(`missing Thanks @${handle} for #${entry.number}`);
}
}
}
return errors;
}
function releaseChecks(section, releaseTags) {
const expected = section.source;
const checks = [];
for (const tag of releaseTags) {
const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]);
const suffix = release.body.slice(expected.length).trimStart();
const matches =
release.body === expected ||
(release.body.startsWith(expected) && (suffix === "" || suffix.startsWith("### Release verification")));
checks.push({
tag,
releaseId: release.id,
matches,
bodyLength: release.body.length,
});
}
return checks;
}
function main() {
const options = parseArgs(process.argv.slice(2));
let changelog = readFileSync("CHANGELOG.md", "utf8");
let section = sectionFor(changelog, options.version);
const source = sourceCommits(options.base, options.target);
const preexistingNotes = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "");
const noteReferences = referencesIn(preexistingNotes);
const revertedNoteReferences = noteReferences.filter((number) => source.revertedReferences.has(number));
if (revertedNoteReferences.length > 0) {
fail(
`release notes reference reverted work: ${[
...new Set(revertedNoteReferences),
]
.map((number) => `#${number}`)
.join(", ")}`,
);
}
const references = [...source.references];
appendReferences(references, noteReferences);
const nodes = resolveReferences(references);
const coauthorHandles = [...source.coauthorsByReference.values()].flatMap((handles) => [...handles]);
const resolvedCoauthors = resolveCoauthors(coauthorHandles);
const ledger = ledgerFor(
options.base,
options.target,
references,
nodes,
source.coauthorsByReference,
resolvedCoauthors,
);
if (options.writeLedger) {
changelog = replaceLedger(changelog, section, ledger.ledger);
writeFileSync("CHANGELOG.md", changelog);
section = sectionFor(changelog, options.version);
}
const errors = ledgerChecks(section, ledger.entries);
const github = options.checkGithub ? releaseChecks(section, options.releaseTags) : [];
for (const check of github) {
if (!check.matches) {
errors.push(`GitHub release ${check.tag} does not match the ${options.version} CHANGELOG section`);
}
}
const result = {
base: options.base,
target: options.target,
mergeBase: source.mergeBase,
version: options.version,
source: {
references: references.length,
pullRequests: ledger.pullRequests.length,
issues: ledger.issues.length,
},
github,
errors,
};
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(
`${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}\n`,
);
}
if (errors.length > 0) {
process.exitCode = 1;
}
}
main();

View File

@@ -16,6 +16,10 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
- A model-list response proves authentication, not billing or inference
entitlement. Mandatory live providers must pass a real completion probe
before release dispatch. Fix the credential first; do not add an alternate
auth path merely to bypass a failed release credential.
- Full Release Validation parent monitors fail fast: once a required child job
fails, the parent cancels the remaining child matrix and prints the failed
job summary. Inspect that first red job instead of waiting for unrelated
@@ -36,6 +40,8 @@ git rev-parse HEAD
preflight. Inject those exact targeted keys first, then run the verifier; use
ambient env only when it was already intentionally injected for this release.
The script prints only provider status and HTTP class, never tokens.
The Anthropic check performs a tiny message completion so exhausted or
non-billable credentials fail before the expensive release matrix.
## Dispatch
@@ -113,7 +119,10 @@ Stop watchers before ending the turn or switching strategy.
--jq '.jobs[] | select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled") | [.databaseId,.name,.conclusion,.url] | @tsv'
```
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
4. For secret-looking failures, validate a real completion from the same secret source before editing code. A successful model-list request is insufficient.
Claude CLI subscription credentials are a separate native auth path; prove
them in a clean-home CLI probe, never as a substitute for a required
Anthropic API-key lane.
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.

View File

@@ -1,17 +1,22 @@
#!/usr/bin/env node
/**
* Release preflight helper that verifies required provider API keys can reach
* their model-list endpoints without printing secret values.
* Release preflight helper that verifies required provider API keys without
* printing secret values. Anthropic must complete a prompt because model-list
* access does not prove billing or inference entitlement.
*/
import process from "node:process";
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const arg = process.argv[index];
if (!arg.startsWith("--")) continue;
if (!arg.startsWith("--")) {
continue;
}
const [key, inlineValue] = arg.slice(2).split("=", 2);
const value = inlineValue ?? process.argv[index + 1];
if (inlineValue === undefined) index += 1;
if (inlineValue === undefined) {
index += 1;
}
args.set(key, value);
}
@@ -28,7 +33,9 @@ const timeoutMs = Number(args.get("timeout-ms") ?? 10_000);
function envFirst(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return { name, value };
if (value) {
return { name, value };
}
}
return undefined;
}
@@ -44,13 +51,19 @@ async function checkProvider(id, config) {
try {
const headers = config.headers(secret.value);
const response = await fetch(config.url, {
body: config.body,
headers,
method: config.method,
signal: controller.signal,
});
const responseBody = config.validateResponse
? await response.json().catch(() => undefined)
: undefined;
const ok = response.ok && (!config.validateResponse || config.validateResponse(responseBody));
return {
id,
ok: response.ok,
status: response.ok ? "ok" : `http_${response.status}`,
ok,
status: response.ok ? (ok ? "ok" : "invalid_response") : `http_${response.status}`,
env: secret.name,
};
} catch (error) {
@@ -73,11 +86,21 @@ const providers = {
},
anthropic: {
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
url: "https://api.anthropic.com/v1/models",
url: "https://api.anthropic.com/v1/messages",
method: "POST",
body: JSON.stringify({
max_tokens: 8,
messages: [{ role: "user", content: "Reply with OK." }],
model: "claude-haiku-4-5",
}),
headers: (token) => ({
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"x-api-key": token,
}),
validateResponse: (body) =>
Array.isArray(body?.content) &&
body.content.some((part) => typeof part?.text === "string" && part.text.trim()),
},
fireworks: {
env: ["FIREWORKS_API_KEY"],
@@ -108,7 +131,9 @@ let failed = false;
for (const result of results) {
const requiredLabel = required.has(result.id) ? "required" : "optional";
console.log(`${result.id}: ${result.status} env=${result.env} ${requiredLabel}`);
if (required.has(result.id) && !result.ok) failed = true;
if (required.has(result.id) && !result.ok) {
failed = true;
}
}
if (failed) {

View File

@@ -100,6 +100,26 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
## Close stable releases on main
Stable publication is not complete until `main` carries the actual shipped release state.
1. Start from fresh latest `main`. Audit `release/YYYY.M.PATCH` against it and
forward-port real fixes that are absent from `main`. Do not blindly merge
release-only compatibility, test, or validation adapters into newer `main`.
2. Set `main` to the shipped stable version, not a speculative next train. Run
`pnpm release:prep` after the root version change, then
`pnpm deps:shrinkwrap:generate`.
3. Make `CHANGELOG.md`'s `## YYYY.M.PATCH` section on `main` exactly match the
tagged release branch. Include the stable `appcast.xml` update when the mac
release published one.
4. Do not add `YYYY.M.PATCH+1`, a beta version, or an empty future changelog
section to `main` until the operator explicitly starts that release train.
5. Run `pnpm release:generated:check`, `pnpm deps:shrinkwrap:check`, and
`OPENCLAW_TESTBOX=1 pnpm check:changed`. Push, then verify `origin/main`
contains the shipped version and changelog before calling the stable release
done.
## Handle versions and release files consistently
- Version locations include:
@@ -205,6 +225,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- Before publishing or closing a release, run
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
and beta release tag in the train. Do not publish or leave a page live when
it is missing a source-history reference, eligible human credit, or the
complete matching changelog body.
- To update an existing GitHub Release body, resolve the numeric release id and
patch that resource with the notes file as the `body` field:
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
@@ -773,13 +798,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
or cherry-pick release branch changes back to `main` after stable succeeds.
private mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
33. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
34. After publish, verify npm and the attached release artifacts.
34. After stable main closeout, verify npm and the attached release artifacts.
## GHSA advisory work

5
.github/CODEOWNERS vendored
View File

@@ -12,9 +12,14 @@
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
/.gitignore @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops

View File

@@ -61,7 +61,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -188,7 +188,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
timeout --signal=TERM --kill-after=10s 120s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -76,7 +76,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -106,7 +106,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
timeout --signal=TERM --kill-after=10s 120s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -6,6 +6,10 @@ on:
type: string
description: "Testbox session ID"
required: true
timeout_minutes:
type: number
description: "Maximum GitHub job runtime for long Testbox commands"
default: 120
pull_request:
paths:
- ".github/workflows/**"
@@ -25,7 +29,7 @@ jobs:
contents: read
name: "check"
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
@@ -61,7 +65,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -91,7 +95,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
timeout --signal=TERM --kill-after=10s 120s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -90,7 +90,7 @@ jobs:
local ref="$1"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
"+${ref}:refs/remotes/origin/checkout" && return 0
@@ -351,7 +351,7 @@ jobs:
local ref="$1"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${ref}:refs/remotes/origin/checkout" && return 0
@@ -499,7 +499,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -564,7 +564,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -810,7 +810,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -850,10 +850,10 @@ jobs:
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
@@ -899,7 +899,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -979,7 +979,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1056,7 +1056,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1131,7 +1131,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1258,7 +1258,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1399,7 +1399,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1584,7 +1584,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1630,7 +1630,7 @@ jobs:
git -C "$workdir" config gc.auto 0
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
@@ -1677,7 +1677,7 @@ jobs:
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
@@ -2083,7 +2083,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1

View File

@@ -476,19 +476,21 @@ jobs:
- name: Run Rocky Linux installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
- name: Run Rocky Linux CLI installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
bun_global_install_smoke:

View File

@@ -407,12 +407,28 @@ jobs:
const path = require("node:path");
const packageDir = process.env.PACKAGE_DIR;
function resolveTarballFileName(value, label) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error(`${label} must be a local .tgz filename.`);
}
return fileName;
}
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
const candidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!candidateFileName) {
const selectedCandidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!selectedCandidateFileName) {
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
}
const candidateFileName = resolveTarballFileName(
selectedCandidateFileName,
"candidate_file_name",
);
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
}
@@ -474,12 +490,23 @@ jobs:
run: |
node <<'NODE' >>"$GITHUB_OUTPUT"
const fs = require("node:fs");
const path = require("node:path");
function resolveTarballFileName(value, label) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error(`${label} must be a local .tgz filename.`);
}
return fileName;
}
const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8"));
const entry = Array.isArray(payload) ? payload.at(-1) : null;
if (!entry?.filename) {
throw new Error("Baseline npm pack did not produce a filename.");
}
process.stdout.write(`file_name=${entry.filename}\n`);
const fileName = resolveTarballFileName(entry?.filename, "Baseline npm pack filename");
process.stdout.write(`file_name=${fileName}\n`);
NODE
- name: Upload candidate artifact

View File

@@ -2222,7 +2222,11 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
else
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
fi
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
@@ -2447,7 +2451,11 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
else
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
fi
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"

View File

@@ -223,10 +223,25 @@ jobs:
set -euo pipefail
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
npm pack --json 2>&1 | tee "$PACK_OUTPUT"
PACK_PATH="$(node - "$PACK_OUTPUT" <<'NODE'
PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const input = fs.readFileSync(process.argv[2], "utf8");
function resolveTarballFileName(value) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
console.error(`npm pack reported unsafe tarball filename ${JSON.stringify(fileName)}.`);
process.exit(1);
}
return fileName;
}
function arrayEndFrom(start) {
let depth = 0;
let inString = false;
@@ -266,8 +281,8 @@ jobs:
try {
const parsed = JSON.parse(input.slice(start, end));
const first = Array.isArray(parsed) ? parsed[0] : null;
if (first && typeof first.filename === "string" && first.filename) {
process.stdout.write(first.filename);
if (first && Object.prototype.hasOwnProperty.call(first, "filename")) {
process.stdout.write(resolveTarballFileName(first.filename));
process.exit(0);
}
} catch {
@@ -279,6 +294,7 @@ jobs:
process.exit(1);
NODE
)"
PACK_PATH="$PWD/$PACK_NAME"
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
echo "npm pack did not produce a tarball file." >&2
exit 1
@@ -290,7 +306,7 @@ jobs:
else
RELEASE_TAG="${RELEASE_REF}"
fi
TARBALL_NAME="$(basename "$PACK_PATH")"
TARBALL_NAME="$PACK_NAME"
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR"

View File

@@ -0,0 +1,55 @@
name: Security Sensitive Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security-sensitive-guard-detect:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Detect security-sensitive changes
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs
security-sensitive-guard:
if: ${{ !github.event.pull_request.draft && always() }}
needs:
- security-sensitive-guard-detect
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs

View File

@@ -65,7 +65,9 @@ jobs:
fi
runner_ssh_port="${BLACKSMITH_SSH_PORT:-22}"
response="$(curl -s -f -L --post302 --post303 -X POST "${api_url}/api/testbox/phone-home" \
hydrating_response="$RUNNER_TEMP/testbox-hydrating.response"
hydrating_http_code="$(curl -sS -L --post302 --post303 -o "$hydrating_response" -w '%{http_code}' \
-X POST "${api_url}/api/testbox/phone-home" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${auth_token}" \
-d "{
@@ -77,7 +79,15 @@ jobs:
\"working_directory\": \"${GITHUB_WORKSPACE}\",
\"adopted_run_id\": \"${GITHUB_RUN_ID}\",
\"metadata\": {}
}" 2>/dev/null || true)"
}" || true)"
echo "phone_home_hydrating_http=${hydrating_http_code}"
if [[ ! "$hydrating_http_code" =~ ^2 ]]; then
echo "Blacksmith phone-home hydrating failed; response body:" >&2
cat "$hydrating_response" >&2 || true
exit 1
fi
response="$(cat "$hydrating_response")"
echo "$TESTBOX_ID" > "$state/testbox_id"
echo "$installation_model_id" > "$state/installation_model_id"
@@ -100,12 +110,14 @@ jobs:
fi
ssh_public_key="$(cat "$state/ssh_public_key" 2>/dev/null || true)"
if [ -n "$ssh_public_key" ]; then
mkdir -p ~/.ssh
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
if [ -z "$ssh_public_key" ]; then
echo "Blacksmith phone-home did not return an SSH public key; testbox cannot accept CLI connections." >&2
exit 1
fi
mkdir -p ~/.ssh
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
- name: Checkout
uses: actions/checkout@v6
@@ -161,6 +173,11 @@ jobs:
-H "Authorization: Bearer ${auth_token}" \
--data-binary @"$ready_body" || true)"
echo "phone_home_ready_http=${http_code}"
if [[ ! "$http_code" =~ ^2 ]]; then
echo "Blacksmith phone-home ready failed; response body:" >&2
cat "$RUNNER_TEMP/testbox-ready.response" >&2 || true
exit 1
fi
echo "============================================"
echo "Testbox ready!"

7
.gitignore vendored
View File

@@ -77,12 +77,19 @@ extensions/canvas/src/host/a2ui/*.map
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/android/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
apps/android/fastlane/report.xml
apps/android/fastlane/Preview.html
apps/android/fastlane/test_output/
apps/android/fastlane/logs/
apps/android/fastlane/.env
apps/android/fastlane/metadata/android/**/images/
# fastlane build artifacts (local)
apps/ios/*.ipa

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,48 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.8</title>
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606000890</sparkle:version>
<sparkle:shortVersionString>2026.6.8</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.8</h2>
<h3>Highlights</h3>
<ul>
<li>Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, preserved intentional line breaks, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #93164, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.</li>
<li>Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, auto-reply message-tool final replies, reset archive fallback reads, restart shutdown aborts, yielded subagent pauses, trusted subagent thinking override fallback, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #92879, #91357, #92631, #92412, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @masatohoshino, @CadanHu, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.</li>
<li>Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, OAuth image-default routing through Codex, bounded model browse discovery, LM Studio binary thinking-off delivery, storeless OpenAI Responses replay gating, invalid OpenAI reasoning-signature and genericized Anthropic thinking-signature recovery, Claude 4.5 Copilot tool-streaming safety, and OpenAI/Anthropic-family payload quarantine for unreadable or post-hook tool schemas. (#92796, #90116, #92627, #91218, #90686, #92824, #92247, #92002, #90706, #92941, #92201, #92916, #75393, #92908, #92921, #92928) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @nxmxbbd, @bek91, @samson910022, @mmyzwl, @CarlCapital, @snowzlm, @Kailigithub, and @vincentkoc.</li>
<li><code>/usage</code> and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
<li>UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin.</li>
<li>Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, full memory reindexes preserve rollback/cache recovery, raw Memory Wiki source pages stop looking malformed, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, @arlen8411, and @yhterrance.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle.</li>
<li>Web search: keep key-free providers such as Parallel Free, DuckDuckGo, Ollama, and Codex Hosted Search as explicit opt-ins instead of selecting them automatically when no API-backed provider is configured. (#93616) Thanks @davemorin and @vincentkoc.</li>
<li>Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including preserved intentional line breaks, rich prompt handoff to CLI backends, and transport fixtures for richer drafts. (#92679, #93164, #92513) Thanks @obviyus and @TurboTheTurtle.</li>
<li>Agent commands: support <code>/btw</code> in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.</li>
<li>Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
<li>Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound <code>message_sent</code> hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.</li>
<li>Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.</li>
<li>Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.</li>
<li>Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.</li>
<li>Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, preserve full-reindex rollback/cache recovery, treat raw Memory Wiki source pages as source evidence, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, and @arlen8411.</li>
<li>UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved <code>/model</code> confirmation refs, stale foreground iOS Gateway reconnects, and paused setup-parent stdin after inherited-stdio child exit. (#90658, #92622, #91353, #92705, #92779, #92773, #92552, #93159) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, @Solvely-Colin, and @fuller-stack-dev.</li>
<li>Plugins and updates: repair missing required platform packages during managed plugin installs and updates, including omitted Codex platform binaries.</li>
<li>Dependencies: update Hono to 4.12.25 so published OpenClaw and ACPX packages use the patched runtime.</li>
<li>Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, fold Telegram RTT sampling into live QA evidence, simplify QA scorecard mappings around canonical coverage IDs, keep QA Lab bootstrap selection assertions aligned with flow-only scenarios, skip QA coverage artifact consumers when runtime parity producer status is not green, keep Feishu lifecycle release checks pointed at the active fixture config, isolate trajectory-export live seed turns from Codex-native shell approvals, preserve release-check child refs while pinning expected SHAs, widen live OpenAI TTS budgets for slower provider responses, and avoid false downgrade prompts for unresolved latest-tag updates. (#92652, #92550, #92558, #92911) Thanks @RomneyDa and @Andy312432.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.8/OpenClaw-2026.6.8.zip" length="55815364" type="application/octet-stream" sparkle:edSignature="hLJ14xg6+DMFrXViIW3Njs++OPIGO+RWH9h+mPCSzXPAkKyYUGvtOLu1qEKvvfC8rs5FGgW/w4zDLfD2azqiBA=="/>
</item>
<item>
<title>2026.6.5</title>
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
@@ -209,69 +251,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
</item>
<item>
<title>2026.5.28</title>
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052890</sparkle:version>
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
<h3>Highlights</h3>
<ul>
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Status: show active subagent details in status output.</li>
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
</channel>
</rss>

11
apps/android/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# OpenClaw Android Changelog
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1,14 @@
{
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"assetPath": "android/openclaw",
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
"materializedRoot": "apps/android/build/release-signing",
"gradlePropertyNames": [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
]
}

View File

@@ -0,0 +1,6 @@
# Shared Android version defaults.
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201

View File

@@ -32,7 +32,7 @@ cd apps/android
./gradlew :app:installPlayDebug
./gradlew :app:testPlayDebugUnitTest
cd ../..
bun run android:bundle:release
pnpm android:release:archive
```
Third-party debug flavor:
@@ -44,10 +44,39 @@ cd apps/android
./gradlew :app:testThirdPartyDebugUnitTest
```
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
Release-owner signing sync:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
Generate raw Google Play screenshots:
```bash
pnpm android:screenshots
```
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
- Play build: `openclaw-<version>-play-release.aab`
- Third-party build: `openclaw-<version>-third-party-release.apk`
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
Flavor-specific direct Gradle tasks:

View File

@@ -0,0 +1,65 @@
# OpenClaw Android Versioning
Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`.
## Version model
- `apps/android/version.json` is the source of truth.
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
Examples:
- `version = 2026.6.2`
- `versionCode = 2026060201`
- another upload on the same release train: `versionCode = 2026060202`
## Commands
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
pnpm android:release:preflight
```
## Release-note resolution order
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
1. exact pinned version, for example `## 2026.6.2`
2. `## Unreleased`
Recommended workflow:
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
## Release Workflow
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.

View File

@@ -1,6 +1,24 @@
import com.android.build.api.variant.impl.VariantOutputImpl
import java.util.Properties
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
val openClawAndroidVersionCode =
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
@@ -65,8 +83,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026060201
versionName = "2026.6.2"
versionCode = openClawAndroidVersionCode
versionName = openClawAndroidVersionName
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -0,0 +1,28 @@
package ai.openclaw.app
import android.content.Intent
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
enum class AndroidScreenshotScene(
val rawValue: String,
) {
Connect("connect"),
Chat("chat"),
Voice("voice"),
Screen("screen"),
Settings("settings"),
;
companion object {
fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Connect
}
}
fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? {
if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) {
return null
}
return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene))
}

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.ui.AndroidScreenshotModeScreen
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
@@ -51,6 +52,12 @@ class MainActivity : ComponentActivity() {
pendingIntent = intent
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
if (BuildConfig.DEBUG) {
parseAndroidScreenshotModeIntent(intent)?.let { scene ->
enterScreenshotMode(scene)
return
}
}
setContent {
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
@@ -79,6 +86,12 @@ class MainActivity : ComponentActivity() {
}
}
private fun enterScreenshotMode(scene: AndroidScreenshotScene) {
setContent {
AndroidScreenshotModeScreen(scene = scene)
}
}
override fun onStart() {
super.onStart()
foreground = true

View File

@@ -111,6 +111,8 @@ class MainViewModel(
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> =
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }

View File

@@ -69,6 +69,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -301,6 +302,8 @@ class NodeRuntime(
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -395,6 +398,7 @@ class NodeRuntime(
val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -452,6 +456,7 @@ class NodeRuntime(
},
onDisconnected = { message ->
operatorConnected = false
invalidateNodeCapabilityApprovalState()
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
@@ -512,12 +517,15 @@ class NodeRuntime(
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
if (endpoint != null && auth != null) {
if (operatorConnected) {
scope.launch { refreshNodesDevicesFromGateway() }
} else if (endpoint != null && auth != null) {
maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
}
},
onDisconnected = { message ->
_nodeConnected.value = false
invalidateNodeCapabilityApprovalState()
nodeStatusText = message
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
@@ -2009,21 +2017,42 @@ class NodeRuntime(
}
private suspend fun refreshNodesDevicesFromGateway() {
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
val refreshGeneration = nodeApprovalRefreshGuard.begin()
val refreshStarted =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
}
if (!refreshStarted) return
if (!operatorConnected) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
_nodesDevicesRefreshing.value = false
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
_nodesDevicesRefreshing.value = false
}
return
}
try {
val nodesRes = operatorSession.request("node.list", "{}")
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
val approvalState =
currentNodeCapabilityApprovalState(
nodes = nodes,
selfNodeId = identityStore.loadOrCreate().deviceId,
)
val publishedApproval =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = approvalState
}
if (!publishedApproval) {
return
}
val devicesRoot =
try {
val devicesRes = operatorSession.request("device.pair.list", "{}")
@@ -2031,16 +2060,30 @@ class NodeRuntime(
} catch (_: Throwable) {
null
}
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray),
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
devicePairingAvailable = devicesRoot != null,
)
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = nodes,
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
devicePairingAvailable = devicesRoot != null,
)
}
} catch (_: Throwable) {
_nodesDevicesErrorText.value = "Could not load nodes and devices."
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesErrorText.value = "Could not load nodes and devices."
}
} finally {
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesRefreshing.value = false
}
}
}
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
_nodesDevicesRefreshing.value = false
}
}
@@ -2289,22 +2332,8 @@ class NodeRuntime(
private fun parseGatewayNodes(nodes: JsonArray?): List<GatewayNodeSummary> =
nodes
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return@mapNotNull null
GatewayNodeSummary(
id = id,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
paired = obj.boolean("paired"),
connected = obj.boolean("connected"),
capabilities = parseStringArray(obj["caps"] as? JsonArray),
commands = parseStringArray(obj["commands"] as? JsonArray),
)
}.orEmpty()
?.mapNotNull(::parseGatewayNodeSummary)
.orEmpty()
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
devices
@@ -2832,6 +2861,81 @@ data class GatewayNodesDevicesSummary(
val devicePairingAvailable: Boolean = true,
)
enum class GatewayNodeApprovalState {
Loading,
Unsupported,
Approved,
PendingApproval,
PendingReapproval,
Unapproved,
}
/** Prevents older node.list responses from overwriting newer approval state. */
internal class GatewayNodeApprovalRefreshGuard {
private val lock = Any()
private var generation = 0L
fun begin(): Long =
synchronized(lock) {
generation += 1
generation
}
fun publishIfCurrent(
refreshGeneration: Long,
publish: () -> Unit,
): Boolean =
synchronized(lock) {
if (refreshGeneration != generation) return@synchronized false
publish()
true
}
}
internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalState =
when (raw?.trim()?.lowercase()) {
null, "" -> GatewayNodeApprovalState.Loading
"approved" -> GatewayNodeApprovalState.Approved
"pending-approval" -> GatewayNodeApprovalState.PendingApproval
"pending-reapproval" -> GatewayNodeApprovalState.PendingReapproval
"unapproved" -> GatewayNodeApprovalState.Unapproved
else -> GatewayNodeApprovalState.Loading
}
internal fun currentNodeCapabilityApprovalState(
nodes: List<GatewayNodeSummary>,
selfNodeId: String,
): GatewayNodeApprovalState =
nodes
.firstOrNull { it.id == selfNodeId }
?.approvalState
?: GatewayNodeApprovalState.Loading
internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayNodeSummary(
id = id,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
paired = obj.boolean("paired"),
connected = obj.boolean("connected"),
// Only an omitted field identifies a legacy gateway; malformed and future values stay fail-closed.
approvalState =
if (obj.containsKey("approvalState")) {
parseGatewayNodeApprovalState(obj["approvalState"].asStringOrNull())
} else {
GatewayNodeApprovalState.Unsupported
},
pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
)
}
data class GatewayNodeSummary(
val id: String,
val displayName: String?,
@@ -2840,6 +2944,8 @@ data class GatewayNodeSummary(
val deviceFamily: String?,
val paired: Boolean,
val connected: Boolean,
val approvalState: GatewayNodeApprovalState,
val pendingRequestId: String?,
val capabilities: List<String>,
val commands: List<String>,
)
@@ -2962,6 +3068,11 @@ private fun JsonObject?.cronStatus(key: String): String? =
?.trim()
?.takeIf { it.isNotEmpty() }
private fun parseGatewayStringArray(items: JsonArray?): List<String> =
items
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
.orEmpty()
fun providerDisplayName(provider: String): String =
when (provider.trim().lowercase()) {
"openai" -> "OpenAI"

View File

@@ -0,0 +1,394 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AndroidScreenshotScene
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun AndroidScreenshotModeScreen(scene: AndroidScreenshotScene) {
ClawDesignTheme(dark = true) {
Column(
modifier =
Modifier
.fillMaxSize()
.background(ClawTheme.colors.canvas)
.padding(horizontal = 20.dp, vertical = 26.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
ScreenshotHeader(scene)
ScreenshotSceneBody(scene = scene, modifier = Modifier.weight(1f))
ScreenshotTabBar(activeScene = scene)
}
}
}
@Composable
private fun ScreenshotHeader(scene: AndroidScreenshotScene) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(text = "OpenClaw", style = ClawTheme.type.title, color = ClawTheme.colors.text)
Text(
text = sceneTitle(scene),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
StatusPill(label = "Connected", color = ClawTheme.colors.success)
}
}
@Composable
private fun ScreenshotSceneBody(
scene: AndroidScreenshotScene,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
when (scene) {
AndroidScreenshotScene.Connect -> ConnectScene()
AndroidScreenshotScene.Chat -> ChatScene()
AndroidScreenshotScene.Voice -> VoiceScene()
AndroidScreenshotScene.Screen -> ScreenScene()
AndroidScreenshotScene.Settings -> SettingsScene()
}
}
}
@Composable
private fun ConnectScene() {
FeaturePanel(icon = Icons.Default.WifiTethering, title = "Gateway paired", subtitle = "Mac Studio - Tailnet") {
MetricRow(label = "Node", value = "Android Pixel 9")
MetricRow(label = "Transport", value = "Secure WebSocket")
MetricRow(label = "Capabilities", value = "Chat, Talk, Camera, Screen")
}
CompactList(
title = "Ready",
rows =
listOf(
"Push wakes active",
"Approvals synced",
"Device tools available",
),
)
}
@Composable
private fun ChatScene() {
ChatBubble(label = "You", text = "Summarize the launch checklist before I start the release.")
ChatBubble(
label = "OpenClaw",
text = "Android archive, Play metadata, and internal testing upload are ready. Screenshots are being refreshed now.",
raised = true,
)
CompactList(
title = "Working set",
rows = listOf("Release notes", "Play bundle", "Device screenshots"),
)
}
@Composable
private fun VoiceScene() {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(196.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = ClawTheme.colors.primary,
modifier = Modifier.size(72.dp),
)
}
}
}
FeaturePanel(icon = Icons.Default.Mic, title = "Talk mode", subtitle = "Listening on device") {
MetricRow(label = "Wake phrase", value = "OpenClaw")
MetricRow(label = "Latency", value = "Realtime")
}
}
@Composable
private fun ScreenScene() {
FeaturePanel(icon = Icons.AutoMirrored.Filled.ScreenShare, title = "Screen tools", subtitle = "Shared with your gateway") {
MetricRow(label = "Canvas", value = "Available")
MetricRow(label = "Camera", value = "Permission granted")
MetricRow(label = "Location", value = "On request")
}
Surface(
modifier = Modifier.fillMaxWidth().height(168.dp),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Live context", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ContextBar(label = "Camera", fraction = 0.74f)
ContextBar(label = "Screen", fraction = 0.58f)
ContextBar(label = "Location", fraction = 0.38f)
}
}
}
@Composable
private fun SettingsScene() {
CompactList(
title = "Security",
rows = listOf("Biometric lock enabled", "Gateway token encrypted", "Tool approvals required"),
)
CompactList(
title = "Notifications",
rows = listOf("Gateway status", "Approval requests", "Background presence"),
)
}
@Composable
private fun FeaturePanel(
icon: ImageVector,
title: String,
subtitle: String,
content: @Composable () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconBox(icon = icon)
Column {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
content()
}
}
}
@Composable
private fun CompactList(
title: String,
rows: List<String>,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
rows.forEach { row ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(ClawTheme.colors.success))
Spacer(modifier = Modifier.width(10.dp))
Text(text = row, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
}
@Composable
private fun ChatBubble(
label: String,
text: String,
raised: Boolean = false,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (raised) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (raised) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
Text(text = text, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
}
@Composable
private fun MetricRow(
label: String,
value: String,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = label, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(
text = value,
style = ClawTheme.type.label,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ContextBar(
label: String,
fraction: Float,
) {
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(7.dp)
.clip(RoundedCornerShape(4.dp))
.background(ClawTheme.colors.surfacePressed),
) {
Box(
modifier =
Modifier
.fillMaxWidth(fraction)
.height(7.dp)
.background(ClawTheme.colors.primary),
)
}
}
}
@Composable
private fun ScreenshotTabBar(activeScene: AndroidScreenshotScene) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TabIcon(icon = Icons.Default.CheckCircle, active = activeScene == AndroidScreenshotScene.Connect)
TabIcon(icon = Icons.Default.ChatBubble, active = activeScene == AndroidScreenshotScene.Chat)
TabIcon(icon = Icons.Default.Mic, active = activeScene == AndroidScreenshotScene.Voice)
TabIcon(icon = Icons.AutoMirrored.Filled.ScreenShare, active = activeScene == AndroidScreenshotScene.Screen)
TabIcon(icon = Icons.Default.Settings, active = activeScene == AndroidScreenshotScene.Settings)
}
}
}
@Composable
private fun TabIcon(
icon: ImageVector,
active: Boolean,
) {
Box(
modifier =
Modifier
.size(42.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (active) ClawTheme.colors.surfacePressed else Color.Transparent),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
modifier = Modifier.size(20.dp),
)
}
}
@Composable
private fun IconBox(icon: ImageVector) {
Box(
modifier =
Modifier
.size(42.dp)
.clip(RoundedCornerShape(8.dp))
.background(ClawTheme.colors.surfacePressed),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = ClawTheme.colors.primary,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun StatusPill(
label: String,
color: Color,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(7.dp))
Text(
text = label,
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold),
color = color,
)
}
}
}
private fun sceneTitle(scene: AndroidScreenshotScene): String =
when (scene) {
AndroidScreenshotScene.Connect -> "Connect"
AndroidScreenshotScene.Chat -> "Chat"
AndroidScreenshotScene.Voice -> "Talk"
AndroidScreenshotScene.Screen -> "Device tools"
AndroidScreenshotScene.Settings -> "Settings"
}

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayDeviceTokenSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPairedDeviceSummary
@@ -155,8 +156,8 @@ private fun NodeRow(node: GatewayNodeSummary) {
badge = nodeBadge(node.displayName ?: node.id),
title = node.displayName ?: node.id,
subtitle = nodeSubtitle(node),
statusText = if (node.connected) "Online" else "Offline",
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
statusText = nodeStatusText(node),
status = nodeStatus(node),
)
}
@@ -205,14 +206,46 @@ private fun nodeSubtitle(node: GatewayNodeSummary): String {
val kind = node.deviceFamily ?: "Node host"
val version = node.version?.let { "OpenClaw $it" }
val status = if (node.paired) "Paired" else "Unpaired"
val approval = nodeApprovalSubtitle(node.approvalState)
val commands =
node.commands
.take(2)
.joinToString(", ")
.takeIf { it.isNotBlank() }
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
return listOfNotNull(kind, version, status, approval, commands).joinToString(" · ")
}
private fun nodeStatusText(node: GatewayNodeSummary): String =
when (node.approvalState) {
GatewayNodeApprovalState.PendingApproval -> "Needs approval"
GatewayNodeApprovalState.PendingReapproval -> "Needs reapproval"
GatewayNodeApprovalState.Unapproved -> "Unapproved"
else -> if (node.connected) "Online" else "Offline"
}
private fun nodeStatus(node: GatewayNodeSummary): ClawStatus =
when (node.approvalState) {
GatewayNodeApprovalState.Approved -> if (node.connected) ClawStatus.Success else ClawStatus.Warning
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
GatewayNodeApprovalState.Unapproved,
-> ClawStatus.Warning
GatewayNodeApprovalState.Loading,
GatewayNodeApprovalState.Unsupported,
-> if (node.connected) ClawStatus.Neutral else ClawStatus.Warning
}
private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): String? =
when (approvalState) {
GatewayNodeApprovalState.Approved -> "Approved"
GatewayNodeApprovalState.PendingApproval -> "Capability approval pending"
GatewayNodeApprovalState.PendingReapproval -> "Capability reapproval pending"
GatewayNodeApprovalState.Unapproved -> "Capability unapproved"
GatewayNodeApprovalState.Loading,
GatewayNodeApprovalState.Unsupported,
-> null
}
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
@@ -139,6 +140,7 @@ fun OnboardingFlow(
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val nodeCapabilityApprovalState by viewModel.nodeCapabilityApprovalState.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -147,7 +149,12 @@ fun OnboardingFlow(
val savedToken by viewModel.gatewayToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
val ready = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
val ready =
canFinishOnboarding(
isConnected = isConnected,
isNodeConnected = isNodeConnected,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
)
var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) }
var setupCode by rememberSaveable { mutableStateOf("") }
@@ -327,6 +334,7 @@ fun OnboardingFlow(
attemptedGatewayName = attemptedGatewayName,
remoteAddress = remoteAddress,
ready = ready,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
onBack = { step = OnboardingStep.Gateway },
@@ -609,6 +617,7 @@ private fun GatewayRecoveryScreen(
attemptedGatewayName: String?,
remoteAddress: String?,
ready: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
gatewayConnectionProblem: GatewayConnectionProblem?,
connectSettling: Boolean,
onBack: () -> Unit,
@@ -617,7 +626,14 @@ private fun GatewayRecoveryScreen(
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
val recoveryState =
gatewayRecoveryUiState(
ready = ready,
statusText = statusText,
connectSettling = connectSettling,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
)
val context = LocalContext.current
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
@@ -629,6 +645,7 @@ private fun GatewayRecoveryScreen(
imageVector =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.WifiTethering
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
@@ -639,6 +656,7 @@ private fun GatewayRecoveryScreen(
tint =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
@@ -658,7 +676,18 @@ private fun GatewayRecoveryScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(
text =
recoveryGatewayDetail(
ready = ready,
remoteAddress = remoteAddress,
statusText = statusText,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
}
@@ -666,6 +695,7 @@ private fun GatewayRecoveryScreen(
text =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> "Healthy"
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> "Node approval"
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
GatewayRecoveryUiState.Pairing -> "Pairing"
GatewayRecoveryUiState.Finishing -> "Connecting"
@@ -674,6 +704,7 @@ private fun GatewayRecoveryScreen(
status =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawStatus.Success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawStatus.Warning
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
@@ -1022,6 +1053,10 @@ internal enum class GatewayRecoveryUiState(
title = "Pairing Gateway",
message = "Approve this phone on the gateway.\nThen retry the connection.",
),
NodeCapabilityApprovalPending(
title = "Node Approval Pending",
message = "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.",
),
Pairing(
title = "Pairing Gateway",
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
@@ -1079,14 +1114,19 @@ internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
connectSettling: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState = GatewayNodeApprovalState.Loading,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): GatewayRecoveryUiState =
when {
ready -> GatewayRecoveryUiState.Connected
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved -> GatewayRecoveryUiState.NodeCapabilityApprovalPending
gatewayConnectionProblem?.isPairingRequired == true &&
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading -> GatewayRecoveryUiState.Finishing
connectSettling -> GatewayRecoveryUiState.Finishing
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
@@ -1170,12 +1210,21 @@ private fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
statusText: String,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
gatewayConnectionProblem: GatewayConnectionProblem?,
): String =
remoteAddress
?.takeIf { it.isNotBlank() }
?: if (ready) {
"Ready for chat and voice"
} else if (
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved
) {
"Gateway paired. Waiting for node capability approval."
} else if (nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading) {
"Gateway paired. Checking node capability approval."
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
?.let { "Gateway approval is pending. Run this on the gateway host:" }
@@ -1248,11 +1297,24 @@ private class PermissionState(
val applyToViewModel: () -> Unit,
)
/** Onboarding can finish only after gateway and node channels are both ready. */
/** Onboarding finishes only after the gateway resolves node capability approval. */
internal fun canFinishOnboarding(
isConnected: Boolean,
isNodeConnected: Boolean,
): Boolean = isConnected && isNodeConnected
nodeCapabilityApprovalState: GatewayNodeApprovalState,
): Boolean =
isConnected &&
isNodeConnected &&
when (nodeCapabilityApprovalState) {
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
GatewayNodeApprovalState.Unapproved,
GatewayNodeApprovalState.Loading,
-> false
GatewayNodeApprovalState.Approved,
GatewayNodeApprovalState.Unsupported,
-> true
}
/** Builds permission rows and applies granted feature toggles after onboarding. */
@Composable

View File

@@ -3,6 +3,7 @@ package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.HomeDestination
@@ -566,7 +567,7 @@ internal fun homeAttentionRows(
} else {
null
},
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
if (nodesDevicesSummary.pendingDevices.isNotEmpty() || nodesDevicesSummary.hasNodeCapabilityApprovalPending()) {
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
} else {
null
@@ -997,6 +998,7 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
val devices = summary.pairedDevices.size
return when {
summary.pendingDevices.isNotEmpty() -> "${summary.pendingDevices.size} pending"
summary.hasNodeCapabilityApprovalPending() -> "Node approval pending"
summary.nodes.isNotEmpty() -> "$online/${summary.nodes.size} online"
devices > 0 -> "$devices paired"
else -> "No devices"
@@ -1007,11 +1009,19 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
when {
summary.pendingDevices.isNotEmpty() -> false
summary.hasNodeCapabilityApprovalPending() -> false
summary.nodes.any { it.connected } -> true
summary.pairedDevices.isNotEmpty() -> true
else -> null
}
private fun GatewayNodesDevicesSummary.hasNodeCapabilityApprovalPending(): Boolean =
nodes.any { node ->
node.approvalState == GatewayNodeApprovalState.PendingApproval ||
node.approvalState == GatewayNodeApprovalState.PendingReapproval ||
node.approvalState == GatewayNodeApprovalState.Unapproved
}
/** Summarizes channel connection state, surfacing errors before connected counts. */
private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
val connected = summary.channels.count { it.connected }

View File

@@ -0,0 +1,42 @@
package ai.openclaw.app
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidScreenshotModeTest {
@Test
fun ignoresNormalLaunches() {
assertNull(parseAndroidScreenshotModeIntent(Intent(Intent.ACTION_MAIN)))
}
@Test
fun parsesRequestedScene() {
val parsed =
parseAndroidScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraAndroidScreenshotMode, true)
.putExtra(extraAndroidScreenshotScene, "voice"),
)
assertEquals(AndroidScreenshotScene.Voice, parsed)
}
@Test
fun defaultsUnknownScenesToConnect() {
val parsed =
parseAndroidScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraAndroidScreenshotMode, true)
.putExtra(extraAndroidScreenshotScene, "unknown"),
)
assertEquals(AndroidScreenshotScene.Connect, parsed)
}
}

View File

@@ -0,0 +1,118 @@
package ai.openclaw.app
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayNodeApprovalStateTest {
@Test
fun parsesGatewayNodeApprovalState() {
assertEquals(GatewayNodeApprovalState.Approved, parseGatewayNodeApprovalState("approved"))
assertEquals(GatewayNodeApprovalState.PendingApproval, parseGatewayNodeApprovalState("pending-approval"))
assertEquals(GatewayNodeApprovalState.PendingReapproval, parseGatewayNodeApprovalState("pending-reapproval"))
assertEquals(GatewayNodeApprovalState.Unapproved, parseGatewayNodeApprovalState("unapproved"))
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState(null))
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state"))
}
@Test
fun parsesNodeListApprovalFields() {
val node =
parseGatewayNodeSummary(
Json.parseToJsonElement(
"""
{
"nodeId": "android-node",
"paired": true,
"connected": true,
"approvalState": "pending-approval",
"pendingRequestId": "request-1",
"caps": ["device"],
"commands": ["device.status"]
}
""".trimIndent(),
),
)
requireNotNull(node)
assertEquals(GatewayNodeApprovalState.PendingApproval, node.approvalState)
assertEquals("request-1", node.pendingRequestId)
assertEquals(listOf("device"), node.capabilities)
assertEquals(listOf("device.status"), node.commands)
}
@Test
fun treatsMissingNodeApprovalStateAsUnsupported() {
val node =
parseGatewayNodeSummary(
Json.parseToJsonElement("""{"nodeId":"android-node","paired":true,"connected":true}"""),
)
requireNotNull(node)
assertEquals(GatewayNodeApprovalState.Unsupported, node.approvalState)
assertEquals(
GatewayNodeApprovalState.Unsupported,
currentNodeCapabilityApprovalState(nodes = listOf(node), selfNodeId = "android-node"),
)
assertNull(node.pendingRequestId)
}
@Test
fun resolvesCurrentPhoneNodeApprovalState() {
val nodes =
listOf(
GatewayNodeSummary(
id = "other",
displayName = null,
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = false,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
GatewayNodeSummary(
id = "self",
displayName = null,
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingApproval,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
)
assertEquals(
GatewayNodeApprovalState.PendingApproval,
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "self"),
)
assertEquals(
GatewayNodeApprovalState.Loading,
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "missing"),
)
}
@Test
fun ignoresStaleNodeApprovalRefreshResults() {
val guard = GatewayNodeApprovalRefreshGuard()
var approvalState = GatewayNodeApprovalState.Loading
val staleRefresh = guard.begin()
val currentRefresh = guard.begin()
assertFalse(guard.publishIfCurrent(staleRefresh) { approvalState = GatewayNodeApprovalState.Approved })
assertTrue(
guard.publishIfCurrent(currentRefresh) { approvalState = GatewayNodeApprovalState.PendingReapproval },
)
assertEquals(GatewayNodeApprovalState.PendingReapproval, approvalState)
}
}

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -9,22 +13,48 @@ import org.junit.Test
class OnboardingFlowLogicTest {
@Test
fun blocksFinishWhenOnlyOperatorIsConnected() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
}
@Test
fun blocksFinishWhenDisconnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false))
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
}
@Test
fun blocksFinishWhenOnlyNodeIsConnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true))
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
}
@Test
fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
fun blocksFinishWhenNodeCapabilityApprovalIsPending() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingReapproval))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unapproved))
}
@Test
fun allowsFinishWhenOperatorNodeAndCapabilityApprovalAreReady() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
}
@Test
fun blocksFinishWhileDelayedNodeListResolvesPendingApproval() =
runTest {
val delayedNodeList = CompletableDeferred<GatewayNodeApprovalState>()
var approvalState = GatewayNodeApprovalState.Loading
val refresh = launch { approvalState = delayedNodeList.await() }
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
delayedNodeList.complete(GatewayNodeApprovalState.PendingApproval)
refresh.join()
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
}
@Test
fun allowsFinishWhenSuccessfulLegacyNodeListOmitsApprovalState() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unsupported))
}
@Test
@@ -98,6 +128,32 @@ class OnboardingFlowLogicTest {
)
}
@Test
fun showsNodeApprovalStateWhenCapabilityApprovalIsPending() {
assertEquals(
GatewayRecoveryUiState.NodeCapabilityApprovalPending,
gatewayRecoveryUiState(
ready = false,
statusText = "Connected",
connectSettling = false,
nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval,
),
)
}
@Test
fun showsFinishingStateWhileNodeApprovalLoads() {
assertEquals(
GatewayRecoveryUiState.Finishing,
gatewayRecoveryUiState(
ready = false,
statusText = "Connected",
connectSettling = false,
nodeCapabilityApprovalState = GatewayNodeApprovalState.Loading,
),
)
}
@Test
fun showsApprovalRequiredForPausedPairingProblem() {
assertEquals(

View File

@@ -3,6 +3,8 @@ package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import org.junit.Assert.assertEquals
@@ -118,6 +120,41 @@ class ShellScreenLogicTest {
assertEquals(emptyList<String>(), rows.map { it.title })
}
@Test
fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() {
val rows =
homeAttentionRows(
isConnected = true,
pendingApprovals = 0,
channelsSummary = emptyChannels(),
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingApproval,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
readyProviderCount = 1,
)
assertEquals(listOf("Nodes & Devices"), rows.map { it.title })
assertEquals("Node approval pending", rows.single().subtitle)
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())

View File

@@ -0,0 +1,20 @@
# Google Play API key (pick one approach)
#
# Recommended local path:
# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
#
# Or raw JSON content for CI:
# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...}
# Optional app targeting
# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
# Release target
# GOOGLE_PLAY_TRACK=internal
# GOOGLE_PLAY_RELEASE_STATUS=completed
# GOOGLE_PLAY_VALIDATE_ONLY=1
# Metadata toggles
# SUPPLY_UPLOAD_METADATA=1
# SUPPLY_UPLOAD_IMAGES=1
# SUPPLY_UPLOAD_SCREENSHOTS=1

View File

@@ -0,0 +1,3 @@
package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app")
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"]

View File

@@ -0,0 +1,389 @@
require "fileutils"
require "json"
require "open3"
require "shellwords"
require "supply/client"
default_platform(:android)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
].freeze
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
def env_present?(value)
!value.nil? && !value.strip.empty?
end
def android_root
File.expand_path("..", __dir__)
end
def repo_root
File.expand_path("../..", android_root)
end
def android_release_signing_script
File.join(repo_root, "scripts", "android-release-signing.mjs")
end
def android_release_signing_materialized_properties_path
File.join(android_root, "build", "release-signing", "gradle.properties")
end
def shell_join(args)
args.shelljoin
end
def play_package_name
raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip
raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw
end
def play_track
raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip
raw.empty? ? DEFAULT_PLAY_TRACK : raw
end
def play_release_status
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
end
def play_validate_only?
ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1"
end
def play_metadata_upload_requested?
ENV["SUPPLY_UPLOAD_METADATA"] == "1"
end
def play_screenshot_upload_requested?
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1"
end
def play_image_upload_requested?
ENV["SUPPLY_UPLOAD_IMAGES"] == "1"
end
def play_auth_options
json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip
json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty?
json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty?
return { json_key: json_key } unless json_key.empty?
json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip
json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty?
return { json_key_data: json_key_data } unless json_key_data.empty?
UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.")
end
def validate_play_auth!
client = nil
begin
client = Supply::Client.make_from_config(params: play_auth_options)
client.begin_edit(package_name: play_package_name)
rescue => e
UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}")
ensure
if client&.current_edit
begin
client.abort_current_edit
rescue => e
UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}")
end
end
end
end
def read_android_version_metadata
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
File.join(repo_root, "scripts", "android-version.ts"),
"--json",
"--root",
repo_root
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read Android version metadata: #{detail}")
end
parsed = JSON.parse(stdout)
version = parsed.fetch("canonicalVersion").to_s
version_code = parsed.fetch("versionCode").to_i
UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0
{ version: version, version_code: version_code }
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from Android version helper: #{e.message}")
end
def sync_android_versioning!
sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check", "--root", repo_root]))
end
def android_release_notes_path
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
end
def validate_android_release_notes!
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
end
def android_changelog_path(version_code)
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def sync_android_changelog!(version_code)
validate_android_release_notes!
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(android_release_notes_path))
changelog_path
end
def play_metadata_path
File.join(__dir__, "metadata", "android")
end
def play_screenshot_paths
Dir[File.join(play_metadata_path, "**", "images", "**", "*.png")]
end
def validate_android_screenshots!
return unless play_screenshot_upload_requested?
if play_screenshot_paths.empty?
UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no PNG screenshots were found under apps/android/fastlane/metadata/android/*/images.")
end
end
def release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
end
def build_release_artifacts!
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
end
def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
properties = {}
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
properties[key] = value.strip
end
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
properties
end
def export_android_release_signing_properties!(path)
read_android_release_signing_properties!(path).each do |key, value|
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
end
end
def sync_android_release_signing!
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
def prepare_android_release_signing!
if env_present?(ENV["MATCH_PASSWORD"])
sync_android_release_signing!
elsif File.exist?(android_release_signing_materialized_properties_path)
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
end
end
def print_android_release_plan!(version_metadata)
UI.message("Android Play release plan:")
UI.message(" package: #{play_package_name}")
UI.message(" track: #{play_track}")
UI.message(" release_status: #{play_release_status}")
UI.message(" validate_only: #{play_validate_only?}")
UI.message(" versionName: #{version_metadata.fetch(:version)}")
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
end
def validate_android_release_preflight!(version_metadata)
validate_play_auth!
prepare_android_release_signing!
validate_android_release_signing!
validate_android_release_notes!
print_android_release_plan!(version_metadata)
end
def upload_play_store_metadata!(version_metadata)
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
track: play_track,
version_code: version_metadata.fetch(:version_code),
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: !play_metadata_upload_requested?,
skip_upload_changelogs: false,
skip_upload_images: !play_image_upload_requested?,
skip_upload_screenshots: !play_screenshot_upload_requested?,
validate_only: play_validate_only?
)
end
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
artifact_path = release_artifact_path(version_metadata.fetch(:version))
UI.user_error!("Missing Play release artifact at #{artifact_path}. Run pnpm android:release:archive first.") unless File.exist?(artifact_path)
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
aab: artifact_path,
track: play_track,
release_status: play_release_status,
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_metadata: !upload_metadata,
skip_upload_changelogs: false,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_screenshots,
validate_only: play_validate_only?
)
end
load_env_file(File.join(__dir__, ".env"))
platform :android do
desc "Validate Google Play API credentials"
lane :auth_check do
validate_play_auth!
UI.success("Google Play API credentials are valid.")
end
desc "Print the Android release signing plan"
lane :signing_plan do
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
end
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
lane :signing_check do
sync_android_release_signing!
validate_android_release_signing!
UI.success("Android release signing assets are available locally.")
end
desc "Pull encrypted Android release signing assets from the shared signing repo"
lane :signing_sync_pull do
sync_android_release_signing!
UI.success("Pulled Android release signing assets.")
end
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
lane :signing_sync_push do
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
UI.success("Pushed Android release signing assets.")
end
desc "Validate Android Play release auth, signing, versioning, and release notes"
lane :release_preflight do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Upload Google Play metadata, changelog, and optional screenshots"
lane :metadata do
sync_android_versioning!
version_metadata = read_android_version_metadata
ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA")
upload_play_store_metadata!(version_metadata)
UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
prepare_android_release_signing!
build_release_artifacts!
end
desc "Generate deterministic Android screenshots for Google Play metadata"
lane :screenshots do
capture_android_screenshots!
end
desc "Upload the signed Play AAB to Google Play"
lane :play_store do
sync_android_versioning!
version_metadata = read_android_version_metadata
upload_play_store_build!(version_metadata)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
end
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
lane :release_upload do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
screenshots
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
build_release_artifacts!
upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
UI.important("Production promotion remains manual in Google Play Console.")
end
end

View File

@@ -0,0 +1,110 @@
# fastlane setup (OpenClaw Android)
Install:
```bash
brew install fastlane
```
Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console.
Recommended local auth:
```bash
GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
```
Optional app targeting:
```bash
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
```bash
MATCH_PASSWORD=<signing repo password> \
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
pnpm android:release:signing:sync:push
```
The source signing properties file must contain:
```properties
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
```
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
Validate auth:
```bash
cd apps/android
fastlane android auth_check
```
Archive locally without upload:
```bash
pnpm android:release:archive
```
Generate deterministic Google Play screenshots:
```bash
pnpm android:screenshots
```
Upload metadata, release notes, and the Play AAB to the internal testing track:
```bash
pnpm android:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/android
fastlane android release_upload
```
Release rules:
- `apps/android/version.json` is the pinned Android release version source.
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
- Production promotion remains manual in Google Play Console.
Screenshots:
- Android screenshot capture writes raw Play screenshots under `apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/`.
- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`.
- Do not commit generated screenshot captures unless they become intentional store metadata assets.

View File

@@ -0,0 +1,3 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1,18 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from Android
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Enable device capabilities such as camera, screen, location, and notifications when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the Android app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -0,0 +1,3 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1 @@
Personal AI on your Android devices

View File

@@ -1,163 +0,0 @@
#!/usr/bin/env bun
/**
* Android release helper that bumps version fields, builds release AAB variants,
* verifies signatures, and prints SHA-256 checksums.
*/
import { $ } from "bun";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
const releaseOutputDir = join(androidDir, "build", "release-bundles");
const releaseVariants = [
{
flavorName: "play",
gradleTask: ":app:bundlePlayRelease",
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
},
{
flavorName: "third-party",
gradleTask: ":app:bundleThirdPartyRelease",
bundlePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"thirdPartyRelease",
"app-thirdParty-release.aab",
),
},
] as const;
type VersionState = {
versionName: string;
versionCode: number;
};
type ParsedVersionMatches = {
versionNameMatch: RegExpMatchArray;
versionCodeMatch: RegExpMatchArray;
};
function formatVersionName(date: Date): string {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}.${month}.${day}`;
}
function formatVersionCodePrefix(date: Date): string {
const year = date.getFullYear().toString();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}${month}${day}`;
}
function parseVersionMatches(buildGradleText: string): ParsedVersionMatches {
const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/);
const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/);
if (!versionCodeMatch || !versionNameMatch) {
throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`);
}
return { versionCodeMatch, versionNameMatch };
}
function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number {
const currentRaw = currentVersionCode.toString();
let nextSuffix = 0;
if (currentRaw.startsWith(todayPrefix)) {
const suffixRaw = currentRaw.slice(todayPrefix.length);
nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1;
}
if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) {
throw new Error(
`Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`,
);
}
return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10);
}
function resolveNextVersion(buildGradleText: string, date: Date): VersionState {
const { versionCodeMatch } = parseVersionMatches(buildGradleText);
const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10);
if (!Number.isInteger(currentVersionCode)) {
throw new Error(`Invalid Android versionCode in ${buildGradlePath}`);
}
const versionName = formatVersionName(date);
const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date));
return { versionName, versionCode };
}
function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string {
return buildGradleText
.replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`)
.replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`);
}
async function sha256Hex(path: string): Promise<string> {
const buffer = await Bun.file(path).arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function verifyBundleSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed bundle missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function main() {
const buildGradleFile = Bun.file(buildGradlePath);
const originalText = await buildGradleFile.text();
const nextVersion = resolveNextVersion(originalText, new Date());
const updatedText = updateBuildGradleVersions(originalText, nextVersion);
if (updatedText === originalText) {
throw new Error("Android version bump produced no change");
}
console.log(`Android versionName -> ${nextVersion.versionName}`);
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
await Bun.write(buildGradlePath, updatedText);
await $`mkdir -p ${releaseOutputDir}`;
try {
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
} catch (error) {
await Bun.write(buildGradlePath, originalText);
throw error;
}
for (const variant of releaseVariants) {
const outputPath = join(
releaseOutputDir,
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
);
await copyBundle(variant.bundlePath, outputPath);
await verifyBundleSignature(outputPath);
const hash = await sha256Hex(outputPath);
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
}
}
await main();

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env bun
/**
* Android release helper that builds signed release artifacts from the pinned
* version metadata, verifies signatures, and writes SHA-256 checksum files.
*/
import { $ } from "bun";
import { existsSync, readdirSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
type ReleaseArtifact = {
flavorName: "play" | "third-party";
kind: "aab" | "apk";
gradleTask: string;
sourcePath: string;
};
type CliOptions = {
dryRun: boolean;
};
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const rootDir = join(androidDir, "..", "..");
const releaseOutputDir = join(androidDir, "build", "release-artifacts");
function parseArgs(argv: string[]): CliOptions {
let dryRun = false;
for (const arg of argv) {
switch (arg) {
case "--dry-run": {
dryRun = true;
break;
}
case "-h":
case "--help": {
console.log(
[
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]",
"",
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
].join("\n"),
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { dryRun };
}
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
return [
{
flavorName: "play",
kind: "aab",
gradleTask: ":app:bundlePlayRelease",
sourcePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"playRelease",
"app-play-release.aab",
),
},
{
flavorName: "third-party",
kind: "apk",
gradleTask: ":app:assembleThirdPartyRelease",
sourcePath: join(
androidDir,
"app",
"build",
"outputs",
"apk",
"thirdParty",
"release",
`openclaw-${versionName}-thirdParty-release.apk`,
),
},
];
}
async function sha256Hex(path: string): Promise<string> {
const buffer = await Bun.file(path).arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function writeSha256File(path: string): Promise<string> {
const hash = await sha256Hex(path);
const checksumPath = `${path}.sha256`;
await Bun.write(checksumPath, `${hash} ${basename(path)}\n`);
return hash;
}
async function verifyAabSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
if (!sdkRoot) {
return null;
}
const buildToolsDir = join(sdkRoot, "build-tools");
if (!existsSync(buildToolsDir)) {
return null;
}
const candidates = readdirSync(buildToolsDir)
.toSorted((left, right) => right.localeCompare(left))
.map((version) => join(buildToolsDir, version, "apksigner"))
.filter((candidate) => existsSync(candidate));
return candidates[0] ?? null;
}
async function resolveApkSigner(): Promise<string> {
const sdkApkSigner =
resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ??
resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT);
if (sdkApkSigner) {
return sdkApkSigner;
}
try {
return (await $`command -v apksigner`.text()).trim();
} catch {
throw new Error(
"Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.",
);
}
}
async function verifyApkSignature(path: string): Promise<void> {
const apkSigner = await resolveApkSigner();
const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], {
stdout: "ignore",
stderr: "inherit",
});
const exitCode = await apkSignerProcess.exited;
if (exitCode !== 0) {
throw new Error(`apksigner verification failed for ${path}`);
}
}
async function copyArtifact(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed release artifact missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise<void> {
if (artifact.kind === "aab") {
await verifyAabSignature(outputPath);
} else {
await verifyApkSignature(outputPath);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
syncAndroidVersioning({ mode: "check", rootDir });
const version = resolveAndroidVersion(rootDir);
const artifacts = releaseArtifacts(version.canonicalVersion);
console.log(`Android versionName: ${version.canonicalVersion}`);
console.log(`Android versionCode: ${version.versionCode}`);
for (const artifact of artifacts) {
console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`);
console.log(`Gradle task: ${artifact.gradleTask}`);
}
if (options.dryRun) {
console.log("Dry run complete. No Gradle tasks were executed.");
return;
}
await $`mkdir -p ${releaseOutputDir}`;
await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir);
for (const artifact of artifacts) {
const outputPath = join(
releaseOutputDir,
`openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`,
);
await copyArtifact(artifact.sourcePath, outputPath);
await verifyArtifactSignature(artifact, outputPath);
const hash = await writeSha256File(outputPath);
console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${artifact.flavorName}): ${hash}`);
}
}
await main();

View File

@@ -0,0 +1,4 @@
{
"version": "2026.6.2",
"versionCode": 2026060201
}

View File

@@ -1,8 +1,8 @@
{
"teamId": "FWJYW4S8P8",
"signingRepo": "git@github.com:openclaw/ios-signing.git",
"certificateType": "IOS_DISTRIBUTION",
"profileType": "IOS_APP_STORE",
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"targets": [
{
"target": "OpenClaw",

View File

@@ -56,17 +56,17 @@ Prereqs:
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
- `asc` CLI authenticated for the canonical OpenClaw team
- Release-owner access to the encrypted signing repo password (`ASC_MATCH_PASSWORD`)
- Fastlane Apple Developer Portal session for the canonical OpenClaw team when creating bundle IDs or enabling services
- Release-owner access to the encrypted signing repo password (`MATCH_PASSWORD`)
- App Store Connect app already created for `ai.openclawfoundation.app`
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
- App Store Connect API key set up in Keychain via `scripts/ios-app-store-connect-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
Release behavior:
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- `asc` owns one-time Developer Portal setup and encrypted signing sync. Fastlane owns release handling after those assets exist.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
@@ -93,16 +93,16 @@ Signing setup commands:
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
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 `ASC_MATCH_PASSWORD`.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- 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 revoking/replacing the Developer Portal certificate or profile with `asc`, then pushing a fresh encrypted sync state.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
Prepare the generated release xcconfig/project without archiving:
@@ -142,13 +142,13 @@ fastlane ios auth_check
2. If auth is missing, bootstrap it once on this Mac:
```bash
scripts/ios-asc-keychain-setup.sh \
scripts/ios-app-store-connect-keychain-setup.sh \
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
--issuer-id YOUR_ISSUER_ID \
--write-env
```
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
This should create `apps/ios/fastlane/.env` with non-secret App Store Connect variables while the private key stays in Keychain.
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
- `ai.openclawfoundation.app`
@@ -157,7 +157,7 @@ This should create `apps/ios/fastlane/.env` with the non-secret ASC variables wh
- `ai.openclawfoundation.app.watchkitapp`
- `ai.openclawfoundation.app.watchkitapp.extension`
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted signing assets to the shared private repo.
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.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.

View File

@@ -30,27 +30,13 @@ struct GatewayQuickSetupSheet: View {
}
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
.font(.headline)
Text(verbatim: candidate.debugID)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
GatewayQuickSetupCandidatePanel(
name: candidate.name,
debugID: candidate.debugID,
discoveryStatusText: self.gatewayController.discoveryStatusText,
gatewayDisplayStatusText: self.appModel.gatewayDisplayStatusText,
nodeStatusText: self.appModel.nodeStatusText,
operatorStatusText: self.appModel.operatorStatusText)
Button {
self.connectError = nil
@@ -169,3 +155,43 @@ struct GatewayQuickSetupSheet: View {
self.connectError = err
}
}
private struct GatewayQuickSetupCandidatePanel: View {
private static let readableMonospaceWidth: CGFloat = 72 * 8
let name: String
let debugID: String
let discoveryStatusText: String
let gatewayDisplayStatusText: String
let nodeStatusText: String
let operatorStatusText: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: self.name)
.font(.system(.headline, design: .monospaced))
.foregroundStyle(.primary)
Text(verbatim: self.debugID)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.discoveryStatusText)")
Text(verbatim: "Status: \(self.gatewayDisplayStatusText)")
Text(verbatim: "Node: \(self.nodeStatusText)")
Text(verbatim: "Operator: \(self.operatorStatusText)")
}
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
}
.frame(maxWidth: Self.readableMonospaceWidth, alignment: .leading)
.padding(.vertical, 14)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}

View File

@@ -18,6 +18,11 @@ private struct GatewayRelayIdentityResponse: Decodable {
let publicKey: String
}
private struct WatchChatPreview {
var items: [OpenClawWatchChatItem]
var statusText: String?
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -54,6 +59,8 @@ private enum IOSDeepLinkAgentPolicy {
@Observable
// swiftlint:disable type_body_length file_length
final class NodeAppModel {
private nonisolated static let watchChatPreviewItemLimit = 5
struct AgentDeepLinkPrompt: Identifiable, Equatable {
let id: String
let messagePreview: String
@@ -191,6 +198,8 @@ final class NodeAppModel {
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
@ObservationIgnored private let watchChatCoordinator = WatchChatCoordinator()
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
private var pendingForegroundActionDrainInFlight = false
@@ -243,6 +252,7 @@ final class NodeAppModel {
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
private static let foregroundResumeHealthTimeoutSeconds = 1
private static let watchChatCompletionWaitMs = 45000
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@@ -314,6 +324,19 @@ final class NodeAppModel {
await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
}
}
self.watchMessagingService.setAppSnapshotRequestHandler { [weak self] event in
Task { @MainActor in
guard let self else { return }
GatewayDiagnostics.log(
"node app model: watch app snapshot request id=\(event.requestId)")
await self.syncWatchAppSnapshot(reason: "watch_app_request", includeChat: true)
}
}
self.watchMessagingService.setAppCommandHandler { [weak self] event in
Task { @MainActor in
await self?.handleWatchAppCommand(event)
}
}
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
@@ -1910,6 +1933,14 @@ extension NodeAppModel {
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
}
var chatAgentAvatarURL: String? {
self.agentIdentityValue(for: self.chatAgentId, key: "avatarUrl")
}
var chatAgentAvatarText: String? {
self.agentIdentityValue(for: self.chatAgentId, key: "emoji")
}
var activeAgentName: String {
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
}
@@ -1930,6 +1961,18 @@ extension NodeAppModel {
return resolvedId
}
private func agentIdentityValue(for agentId: String, key: String) -> String? {
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !resolvedId.isEmpty,
let match = self.gatewayAgents.first(where: { $0.id == resolvedId }),
let rawValue = match.identity?[key]?.value as? String
else {
return nil
}
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
func connectToGateway(
url: URL,
gatewayStableID: String,
@@ -2802,9 +2845,22 @@ extension NodeAppModel {
}
private func setOperatorConnected(_ connected: Bool) {
let changed = self.operatorConnected != connected
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
guard connected else {
guard changed else { return }
Task { [weak self] in
await self?.syncWatchAppSnapshot(reason: "operator_offline")
}
return
}
Task { [weak self] in
await self?.flushQueuedWatchChatsIfAvailable()
guard changed else { return }
await self?.syncWatchAppSnapshot(reason: "operator_online")
}
}
private func refreshOperatorAdminScopeFromStore() {
@@ -3011,6 +3067,7 @@ extension NodeAppModel {
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true)
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
}
@@ -3215,10 +3272,11 @@ extension NodeAppModel {
"watch exec approval: status changed "
+ "reachable=\(status.reachable) activation=\(status.activationState) "
+ "backgrounded=\(self.isBackgrounded)")
guard self.isBackgrounded else { return }
guard status.supported, status.paired, status.appInstalled else { return }
guard status.reachable || status.activationState == "activated" else { return }
let reason = status.reachable ? "watch_reachable" : "watch_activated"
await self.syncWatchAppSnapshot(reason: reason, includeChat: status.reachable)
guard self.isBackgrounded else { return }
await self.syncWatchExecApprovalSnapshot(reason: reason)
}
@@ -3303,6 +3361,7 @@ extension NodeAppModel {
self.watchExecApprovalLogger.error(
"watch approval prompt error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchAppSnapshot(reason: "\(reason)_app")
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
}
@@ -3328,6 +3387,7 @@ extension NodeAppModel {
self.watchExecApprovalLogger.error(
"watch approval resolve error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchAppSnapshot(reason: "resolved_app")
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
}
@@ -3351,6 +3411,7 @@ extension NodeAppModel {
self.watchExecApprovalLogger.error(
"watch approval expiry error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app")
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
}
@@ -3393,10 +3454,311 @@ extension NodeAppModel {
}
}
private func makeWatchChatPreview() async -> WatchChatPreview {
do {
let payload: OpenClawChatHistoryPayload
if self.isAppleReviewDemoModeEnabled {
payload = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: self.chatSessionKey)
} else {
guard self.isOperatorGatewayConnected else {
return WatchChatPreview(
items: [],
statusText: "Connect iPhone chat to read messages")
}
payload = try await IOSGatewayChatTransport(gateway: self.operatorSession)
.requestHistory(sessionKey: self.chatSessionKey)
}
let items = Self.makeWatchChatItems(from: payload.messages ?? [])
return WatchChatPreview(
items: items,
statusText: items.isEmpty ? "No chat messages yet" : nil)
} catch {
GatewayDiagnostics.log("watch app snapshot: chat preview failed error=\(error.localizedDescription)")
return WatchChatPreview(items: [], statusText: "Chat unavailable")
}
}
private nonisolated static func decodeWatchChatMessage(
_ raw: OpenClawKit.AnyCodable) -> OpenClawChatMessage?
{
guard let data = try? JSONEncoder().encode(raw) else { return nil }
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
}
private nonisolated static func makeWatchChatItems(
from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
{
var readableMessages: [(OpenClawChatMessage, String)] = []
for item in raw.reversed() {
guard let message = self.decodeWatchChatMessage(item) else { continue }
let text = self.watchChatText(from: message)
guard !text.isEmpty else { continue }
readableMessages.append((message, text))
if readableMessages.count == self.watchChatPreviewItemLimit {
break
}
}
return Array(readableMessages.reversed()).enumerated().map { index, entry in
let timestampMs = self.watchTimestampMs(entry.0.timestamp)
let stableTime = timestampMs.map(String.init) ?? entry.0.id.uuidString
return OpenClawWatchChatItem(
id: "\(entry.0.role)-\(stableTime)-\(index)",
role: entry.0.role,
text: self.truncatedWatchChatText(entry.1),
timestampMs: timestampMs)
}
}
private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind.isEmpty || kind == "text" else { return nil }
if let text = self.nonEmptyWatchChatText(content.text) {
return text
}
if let text = self.nonEmptyWatchChatText(content.content?.value as? String) {
return text
}
if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable],
let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String)
{
return text
}
return nil
}
let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !contentText.isEmpty {
return contentText
}
return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? {
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private nonisolated static func truncatedWatchChatText(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > 240 else { return trimmed }
return "\(trimmed.prefix(237))..."
}
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? {
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
let maxReasonableEpochMs: Double = 32_503_680_000_000
guard milliseconds.isFinite,
milliseconds >= 0,
milliseconds <= maxReasonableEpochMs
else {
return nil
}
return Int(milliseconds)
}
private func makeWatchAppSnapshot(
chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage
{
self.pruneExpiredWatchExecApprovalPrompts()
let watchGatewayConnected = self.isAppleReviewDemoModeEnabled
|| (self.gatewayConnected && self.operatorConnected)
let displayStatusText = self.gatewayDisplayStatusText
let watchGatewayStatusText = watchGatewayConnected || displayStatusText != "Connected"
? displayStatusText
: self.operatorStatusText
return OpenClawWatchAppSnapshotMessage(
gatewayStatusText: watchGatewayStatusText,
gatewayConnected: watchGatewayConnected,
agentName: self.chatAgentName,
agentAvatarURL: self.chatAgentAvatarURL,
agentAvatarText: self.chatAgentAvatarText,
sessionKey: self.chatSessionKey,
gatewayStableID: self.currentWatchChatGatewayStableID(),
talkStatusText: self.talkMode.statusText,
talkEnabled: self.talkMode.isEnabled,
talkListening: self.talkMode.isListening,
talkSpeaking: self.talkMode.isSpeaking,
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
chatItems: chatPreview?.items,
chatStatusText: chatPreview?.statusText,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
}
private func handleWatchAppCommand(_ event: WatchAppCommandEvent) async {
GatewayDiagnostics.log(
"watch app command: handle id=\(event.commandId) command=\(event.command.rawValue)")
switch event.command {
case .refresh:
break
case .openChat:
self.openChat(sessionKey: event.sessionKey ?? self.chatSessionKey)
case .sendChat:
await self.handleWatchChatCommand(event)
return
case .startTalk:
guard !self.isAppleReviewDemoModeEnabled else { break }
self.talkMode.updateMainSessionKey(event.sessionKey ?? self.chatSessionKey)
self.setTalkEnabled(true)
case .stopTalk:
self.setTalkEnabled(false)
}
await self.syncWatchAppSnapshot(
reason: "watch_command_\(event.command.rawValue)",
includeChat: true)
}
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
guard self.watchChatCommandTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
return
}
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event)
switch self.watchChatCoordinator.ingest(
event,
isChatAvailable: self.isWatchChatAvailableForSend(),
gatewayStableID: eventGatewayID)
{
case .dropMissingFields:
GatewayDiagnostics.log("watch chat send skipped: missing commandId/text")
case .dropMissingTarget:
GatewayDiagnostics.log("watch chat send skipped: missing gateway target")
case let .deduped(commandId):
GatewayDiagnostics.log("watch chat send deduped commandId=\(commandId)")
case let .queue(commandId):
GatewayDiagnostics.log("watch chat send queued commandId=\(commandId)")
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
case .forward:
_ = await self.forwardWatchChatMessage(event, requeueOnFailure: true)
}
}
private func flushQueuedWatchChatsIfAvailable() async {
let gatewayStableID = self.currentWatchChatGatewayStableID()
while let event = self.watchChatCoordinator.nextQueuedCommand(
isChatAvailable: self.isWatchChatAvailableForSend(),
gatewayStableID: gatewayStableID)
{
guard self.watchChatCommandTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch chat send skipped: stale queued gateway target")
self.watchChatCoordinator.removeQueuedCommand(
commandId: event.commandId,
gatewayStableID: gatewayStableID)
continue
}
let sent = await self.forwardWatchChatMessage(event, requeueOnFailure: false)
guard sent else { return }
self.watchChatCoordinator.removeQueuedCommand(
commandId: event.commandId,
gatewayStableID: gatewayStableID)
}
}
private func isWatchChatAvailableForSend() -> Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
private func currentWatchChatGatewayStableID() -> String? {
self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizedWatchChatGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return gatewayStableID.isEmpty ? nil : gatewayStableID
}
private func watchChatCommandTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) ?? ""
let currentGatewayID = self.currentWatchChatGatewayStableID() ?? ""
guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false }
return eventGatewayID == currentGatewayID
}
private func forwardWatchChatMessage(
_ event: WatchAppCommandEvent,
requeueOnFailure: Bool) async -> Bool
{
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else {
GatewayDiagnostics.log("watch chat send skipped: empty text")
return true
}
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? event.sessionKey!
: self.chatSessionKey
self.focusChatSession(sessionKey)
do {
if self.isAppleReviewDemoModeEnabled {
_ = try await self.appleReviewDemoChatTransport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: "auto",
idempotencyKey: event.commandId,
attachments: [])
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
return true
}
guard self.isOperatorGatewayConnected else {
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
if requeueOnFailure {
self.watchChatCoordinator.requeueFront(
event,
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
}
return false
}
let transport = IOSGatewayChatTransport(gateway: self.operatorSession)
let response = try await transport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: "auto",
idempotencyKey: event.commandId,
attachments: [])
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
let completed = await transport.waitForRunCompletion(
runId: response.runId,
timeoutMs: Self.watchChatCompletionWaitMs)
guard completed else { return true }
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
return true
} catch {
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
if requeueOnFailure {
self.watchChatCoordinator.requeueFront(
event,
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
}
return false
}
}
private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async {
let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
do {
_ = try await self.watchMessagingService.syncAppSnapshot(message)
GatewayDiagnostics.log(
"watch app snapshot: sent reason=\(reason) "
+ "connected=\(message.gatewayConnected) approvals=\(message.pendingApprovalCount) "
+ "chatItems=\(message.chatItems?.count ?? -1)")
} catch {
GatewayDiagnostics.log(
"watch app snapshot: failed reason=\(reason) error=\(error.localizedDescription)")
}
}
private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
await self.syncWatchExecApprovalSnapshot(reason: reason)
await self.syncWatchAppSnapshot(reason: "\(reason)_app", includeChat: true)
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
}
@@ -4660,10 +5022,34 @@ extension NodeAppModel {
self.watchReplyCoordinator.queuedCount
}
func _test_queuedWatchChatCommandCount() -> Int {
self.watchChatCoordinator.queuedCount
}
func _test_queuedWatchChatCommandIds() -> [String] {
self.watchChatCoordinator.queuedCommandIds
}
func _test_setConnectedGatewayID(_ gatewayID: String?) {
self.connectedGatewayID = gatewayID
}
static func _test_resetPersistedWatchChatQueueState() {
WatchChatCoordinator.resetPersistedQueue()
}
func _test_setGatewayConnected(_ connected: Bool) {
self.gatewayConnected = connected
}
func _test_setOperatorConnected(_ connected: Bool) {
self.setOperatorConnected(connected)
}
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
self.makeWatchChatItems(from: raw)
}
func _test_isGatewayConnected() -> Bool {
self.gatewayConnected
}

View File

@@ -44,3 +44,160 @@ final class WatchReplyCoordinator {
self.queuedReplies.count
}
}
@MainActor
final class WatchChatCoordinator {
enum Decision {
case dropMissingFields
case dropMissingTarget
case deduped(commandId: String)
case queue(commandId: String)
case forward
}
private static let persistedQueueKey = "watch.chat.command.queue.v1"
private static let maxRecentCommandIds = 128
private struct QueuedCommand: Codable, Equatable {
var gatewayStableID: String
var event: WatchAppCommandEvent
}
private let defaults: UserDefaults
private var queuedCommands: [QueuedCommand] = []
private var recentCommandIds: [String] = []
private var seenCommandIds = Set<String>()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.restoreQueue()
}
func ingest(
_ event: WatchAppCommandEvent,
isChatAvailable: Bool,
gatewayStableID: String?) -> Decision
{
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if commandId.isEmpty || text.isEmpty {
return .dropMissingFields
}
if self.seenCommandIds.contains(commandId) {
return .deduped(commandId: commandId)
}
self.rememberRecentCommandId(commandId)
if !isChatAvailable {
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return .dropMissingTarget }
self.queuedCommands.append(
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)))
self.rebuildSeenCommandIds()
self.persistQueue()
return .queue(commandId: commandId)
}
return .forward
}
func nextQueuedCommand(isChatAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard isChatAvailable, !owner.isEmpty else { return nil }
return self.queuedCommands.first { $0.gatewayStableID == owner }?.event
}
func removeQueuedCommand(commandId: String, gatewayStableID: String?) {
let commandId = commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !commandId.isEmpty, !owner.isEmpty else { return }
guard let index = self.queuedCommands.firstIndex(where: {
$0.gatewayStableID == owner && $0.event.commandId == commandId
}) else { return }
self.queuedCommands.remove(at: index)
self.rememberRecentCommandId(commandId)
self.persistQueue()
}
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return }
if !commandId.isEmpty {
self.rememberRecentCommandId(commandId)
self.queuedCommands.removeAll { $0.event.commandId == commandId }
}
self.queuedCommands.insert(
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)),
at: 0)
self.rebuildSeenCommandIds()
self.persistQueue()
}
var queuedCount: Int {
self.queuedCommands.count
}
var queuedCommandIds: [String] {
self.queuedCommands.map(\.event.commandId)
}
private func restoreQueue() {
guard let data = defaults.data(forKey: Self.persistedQueueKey),
let persisted = try? JSONDecoder().decode([QueuedCommand].self, from: data)
else {
return
}
var seen: [String] = []
var seenSet = Set<String>()
self.queuedCommands = persisted.compactMap { queued in
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let commandId = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty, !commandId.isEmpty, !text.isEmpty, seenSet.insert(commandId).inserted else {
return nil
}
seen.append(commandId)
return QueuedCommand(gatewayStableID: owner, event: self.command(queued.event, taggedFor: owner))
}
self.recentCommandIds = Array(seen.suffix(Self.maxRecentCommandIds))
self.rebuildSeenCommandIds()
if self.queuedCommands.count != persisted.count {
self.persistQueue()
}
}
private func rememberRecentCommandId(_ commandId: String) {
guard !commandId.isEmpty else { return }
self.recentCommandIds.removeAll { $0 == commandId }
self.recentCommandIds.append(commandId)
if self.recentCommandIds.count > Self.maxRecentCommandIds {
self.recentCommandIds.removeFirst(self.recentCommandIds.count - Self.maxRecentCommandIds)
}
self.rebuildSeenCommandIds()
}
private func rebuildSeenCommandIds() {
var ids = Set(self.recentCommandIds)
ids.formUnion(self.queuedCommands.map(\.event.commandId))
self.seenCommandIds = ids
}
private func persistQueue() {
if self.queuedCommands.isEmpty {
self.defaults.removeObject(forKey: Self.persistedQueueKey)
return
}
guard let data = try? JSONEncoder().encode(queuedCommands) else { return }
self.defaults.set(data, forKey: Self.persistedQueueKey)
}
private func command(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
var tagged = event
tagged.gatewayStableID = gatewayStableID
return tagged
}
static func resetPersistedQueue(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: self.persistedQueueKey)
}
}

View File

@@ -97,6 +97,22 @@ struct WatchExecApprovalSnapshotRequestEvent: Equatable {
var transport: String
}
struct WatchAppSnapshotRequestEvent: Equatable {
var requestId: String
var sentAtMs: Int?
var transport: String
}
struct WatchAppCommandEvent: Codable, Equatable {
var commandId: String
var command: OpenClawWatchAppCommand
var sessionKey: String?
var gatewayStableID: String?
var text: String?
var sentAtMs: Int?
var transport: String
}
struct WatchNotificationSendResult: Equatable {
var deliveredImmediately: Bool
var queuedForDelivery: Bool
@@ -110,6 +126,8 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?)
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?)
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
@@ -121,6 +139,8 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
func syncAppSnapshot(
_ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult
}
extension CameraController: CameraServicing {}

View File

@@ -7,6 +7,8 @@ private struct WatchConnectivityTransportCallbacks {
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?
var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
}
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
@@ -96,6 +98,14 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
}
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) {
self.updateCallbacks { $0.appSnapshotRequestHandler = handler }
}
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) {
self.updateCallbacks { $0.appCommandHandler = handler }
}
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
await self.ensureActivated()
let session = try self.requireReadySession()
@@ -227,6 +237,24 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
}
}
private func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) {
guard let handler = self.callbacksSnapshot().appSnapshotRequestHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private func emitAppCommand(_ event: WatchAppCommandEvent) {
guard let handler = self.callbacksSnapshot().appCommandHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private nonisolated static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
@@ -296,6 +324,20 @@ extension WatchConnectivityTransport: WCSessionDelegate {
transport: "sendMessage")
{
self.emitExecApprovalSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
message,
transport: "sendMessage")
{
self.emitAppSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
message,
transport: "sendMessage")
{
self.emitAppCommand(event)
}
}
@@ -327,6 +369,22 @@ extension WatchConnectivityTransport: WCSessionDelegate {
self.emitExecApprovalSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitAppSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitAppCommand(event)
return
}
replyHandler(["ok": false, "error": "unsupported_payload"])
}
@@ -352,6 +410,20 @@ extension WatchConnectivityTransport: WCSessionDelegate {
transport: "transferUserInfo")
{
self.emitExecApprovalSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitAppSnapshotRequest(event)
return
}
if let event = WatchMessagingPayloadCodec.parseAppCommandPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitAppCommand(event)
}
}

View File

@@ -151,6 +151,55 @@ enum WatchMessagingPayloadCodec {
return payload
}
static func encodeAppSnapshotPayload(
_ message: OpenClawWatchAppSnapshotMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.appSnapshot.rawValue,
"gatewayStatusText": message.gatewayStatusText,
"gatewayConnected": message.gatewayConnected,
"agentName": message.agentName,
"sessionKey": message.sessionKey,
"talkStatusText": message.talkStatusText,
"talkEnabled": message.talkEnabled,
"talkListening": message.talkListening,
"talkSpeaking": message.talkSpeaking,
"pendingApprovalCount": message.pendingApprovalCount,
]
if let agentAvatarURL = nonEmpty(message.agentAvatarURL) {
payload["agentAvatarUrl"] = agentAvatarURL
}
if let agentAvatarText = nonEmpty(message.agentAvatarText) {
payload["agentAvatarText"] = agentAvatarText
}
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let chatItems = message.chatItems {
payload["chatItems"] = chatItems.map { item in
var encoded: [String: Any] = [
"id": item.id,
"role": item.role,
"text": item.text,
]
if let timestampMs = item.timestampMs {
encoded["timestampMs"] = timestampMs
}
return encoded
}
}
if let chatStatusText = nonEmpty(message.chatStatusText) {
payload["chatStatusText"] = chatStatusText
}
if let snapshotId = nonEmpty(message.snapshotId) {
payload["snapshotId"] = snapshotId
}
return payload
}
static func parseQuickReplyPayload(
_ payload: [String: Any],
transport: String) -> WatchQuickReplyEvent?
@@ -216,4 +265,46 @@ enum WatchMessagingPayloadCodec {
sentAtMs: sentAtMs,
transport: transport)
}
static func parseAppSnapshotRequestPayload(
_ payload: [String: Any],
transport: String) -> WatchAppSnapshotRequestEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appSnapshotRequest.rawValue else {
return nil
}
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchAppSnapshotRequestEvent(
requestId: requestId,
sentAtMs: sentAtMs,
transport: transport)
}
static func parseAppCommandPayload(
_ payload: [String: Any],
transport: String) -> WatchAppCommandEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appCommand.rawValue else {
return nil
}
guard let rawCommand = nonEmpty(payload["command"] as? String),
let command = OpenClawWatchAppCommand(rawValue: rawCommand)
else {
return nil
}
let commandId = self.nonEmpty(payload["commandId"] as? String) ?? UUID().uuidString
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let text = self.nonEmpty(payload["text"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchAppCommandEvent(
commandId: commandId,
command: command,
sessionKey: sessionKey,
gatewayStableID: gatewayStableID,
text: text,
sentAtMs: sentAtMs,
transport: transport)
}
}

View File

@@ -27,6 +27,8 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (
@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
private var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?
private var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
init(transport: WatchConnectivityTransport = WatchConnectivityTransport()) {
self.transport = transport
@@ -50,6 +52,16 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
self?.emitExecApprovalSnapshotRequest(event)
}
}
self.transport.setAppSnapshotRequestHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitAppSnapshotRequest(event)
}
}
self.transport.setAppCommandHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitAppCommand(event)
}
}
}
nonisolated static func isSupportedOnDevice() -> Bool {
@@ -95,6 +107,14 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
self.execApprovalSnapshotRequestHandler = handler
}
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) {
self.appSnapshotRequestHandler = handler
}
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) {
self.appCommandHandler = handler
}
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
@@ -131,6 +151,13 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload(message))
}
func syncAppSnapshot(
_ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendSnapshotPayload(
WatchMessagingPayloadCodec.encodeAppSnapshotPayload(message))
}
private func emitStatusIfChanged(_ snapshot: WatchMessagingStatus) {
guard snapshot != self.lastEmittedStatus else {
return
@@ -159,4 +186,20 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
+ "sentAtMs=\(event.sentAtMs ?? -1)")
self.execApprovalSnapshotRequestHandler?(event)
}
private func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) {
GatewayDiagnostics.log(
"watch messaging: app snapshot request "
+ "id=\(event.requestId) transport=\(event.transport) "
+ "sentAtMs=\(event.sentAtMs ?? -1)")
self.appSnapshotRequestHandler?(event)
}
private func emitAppCommand(_ event: WatchAppCommandEvent) {
GatewayDiagnostics.log(
"watch messaging: app command "
+ "id=\(event.commandId) command=\(event.command.rawValue) "
+ "transport=\(event.transport)")
self.appCommandHandler?(event)
}
}

View File

@@ -1,4 +1,5 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Testing
@@ -33,6 +34,27 @@ private func makeAgentDeepLinkURL(
return components.url!
}
private func makeWatchChatRawMessage(
role: String,
text: String?,
type: String = "text",
timestamp: Double) throws -> AnyCodable
{
let message = OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: type,
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp)
let data = try JSONEncoder().encode(message)
return try JSONDecoder().decode(AnyCodable.self, from: data)
}
@MainActor
private func mountScreen(_ screen: ScreenController) throws -> ScreenWebViewCoordinator {
let coordinator = ScreenWebViewCoordinator(controller: screen)
@@ -59,10 +81,13 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage?
var lastSentAppSnapshot: OpenClawWatchAppSnapshotMessage?
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
private var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?
private var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
func status() async -> WatchMessagingStatus {
self.currentStatus
@@ -86,6 +111,14 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
self.execApprovalSnapshotRequestHandler = handler
}
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) {
self.appSnapshotRequestHandler = handler
}
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) {
self.appCommandHandler = handler
}
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
self.lastSent = (id: id, params: params)
if let sendError {
@@ -134,6 +167,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
return self.nextSendResult
}
func syncAppSnapshot(
_ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult
{
self.lastSentAppSnapshot = message
if let sendError {
throw sendError
}
return self.nextSendResult
}
func emitReply(_ event: WatchQuickReplyEvent) {
self.replyHandler?(event)
}
@@ -145,6 +188,14 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
self.execApprovalSnapshotRequestHandler?(event)
}
func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) {
self.appSnapshotRequestHandler?(event)
}
func emitAppCommand(_ event: WatchAppCommandEvent) {
self.appCommandHandler?(event)
}
}
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
@@ -462,6 +513,581 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(watchService.lastSentExecApprovalSnapshot == nil)
}
@Test @MainActor func watchAppSnapshotRequestPublishesCurrentDashboardState() async throws {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setGatewayConnected(true)
appModel._test_setOperatorConnected(true)
appModel._test_setConnectedGatewayID("gateway-watch-snapshot")
appModel.gatewayStatusText = "Connected"
appModel.talkMode.setEnabled(true)
appModel.talkMode.statusText = "Listening"
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-1",
sentAtMs: 123,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot != nil {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
let snapshot = try #require(watchService.lastSentAppSnapshot)
#expect(snapshot.gatewayConnected == true)
#expect(snapshot.gatewayStatusText == "Connected")
#expect(snapshot.agentName == "Main")
#expect(snapshot.sessionKey == "main")
#expect(snapshot.gatewayStableID == "gateway-watch-snapshot")
#expect(!snapshot.talkStatusText.isEmpty)
#expect(snapshot.talkEnabled == true)
#expect(snapshot.pendingApprovalCount == 0)
}
@Test @MainActor func watchAppSnapshotPublishesOfflineWhenOperatorDisconnects() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setGatewayConnected(true)
appModel._test_setOperatorConnected(true)
appModel.gatewayStatusText = "Connected"
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-before-disconnect",
sentAtMs: 123,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot?.gatewayConnected == true {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(watchService.lastSentAppSnapshot?.gatewayConnected == true)
appModel.disconnectGateway()
for _ in 0..<20 {
if watchService.lastSentAppSnapshot?.gatewayConnected == false {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(watchService.lastSentAppSnapshot?.gatewayConnected == false)
#expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Offline")
}
@Test @MainActor func watchAppSnapshotPublishesOnlineWhenOperatorReconnects() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setGatewayConnected(true)
appModel.gatewayStatusText = "Connected"
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-before-reconnect",
sentAtMs: 124,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot?.gatewayConnected == false {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(watchService.lastSentAppSnapshot?.gatewayConnected == false)
appModel._test_setOperatorConnected(true)
for _ in 0..<20 {
if watchService.lastSentAppSnapshot?.gatewayConnected == true {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(watchService.lastSentAppSnapshot?.gatewayConnected == true)
#expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Connected")
}
@Test @MainActor func watchAppSnapshotUsesConfiguredAgentAvatar() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.gatewayDefaultAgentId = "main"
appModel.gatewayAgents = [
AgentSummary(
id: "main",
name: "Main",
identity: [
"avatarUrl": AnyCodable("https://example.com/openclaw.png"),
"emoji": AnyCodable("OC"),
],
workspace: nil,
model: nil,
agentruntime: nil),
]
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-avatar",
sentAtMs: 124,
transport: "sendMessage"))
await Task.yield()
let snapshot = try #require(watchService.lastSentAppSnapshot)
#expect(snapshot.agentAvatarURL == "https://example.com/openclaw.png")
#expect(snapshot.agentAvatarText == "OC")
}
@Test @MainActor func watchAppSnapshotIncludesPendingApprovalCount() async throws {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-app-count",
commandText: "rm -rf build",
allowedDecisions: ["allow-once", "deny"],
host: "Mac",
nodeId: "node-1",
agentId: "agent-1",
expiresAtMs: nil)))
await Task.yield()
let snapshot = try #require(watchService.lastSentAppSnapshot)
#expect(snapshot.pendingApprovalCount == 1)
}
@Test @MainActor func watchAppCommandControlsTalkThroughPhoneModel() async {
let watchService = MockWatchMessagingService()
let talkMode = TalkModeManager(allowSimulatorCapture: true)
let appModel = NodeAppModel(watchMessagingService: watchService, talkMode: talkMode)
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-start-talk",
command: .startTalk,
sessionKey: "main",
gatewayStableID: nil,
text: nil,
sentAtMs: 123,
transport: "sendMessage"))
await Task.yield()
#expect(appModel.talkMode.isEnabled == true)
#expect(watchService.lastSentAppSnapshot?.talkEnabled == true)
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-stop-talk",
command: .stopTalk,
sessionKey: "main",
gatewayStableID: nil,
text: nil,
sentAtMs: 124,
transport: "sendMessage"))
await Task.yield()
#expect(appModel.talkMode.isEnabled == false)
#expect(watchService.lastSentAppSnapshot?.talkEnabled == false)
}
@Test @MainActor func watchAppCommandOpensChatSessionOnPhoneModel() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-open-chat",
command: .openChat,
sessionKey: "incident-42",
gatewayStableID: nil,
text: nil,
sentAtMs: 125,
transport: "sendMessage"))
await Task.yield()
#expect(appModel.chatSessionKey == "incident-42")
#expect(watchService.lastSentAppSnapshot?.sessionKey == "incident-42")
}
@Test @MainActor func watchAppCommandSendsChatMessageThroughPhoneModel() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.enterAppleReviewDemoMode()
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat",
command: .sendChat,
sessionKey: "main",
gatewayStableID: AppleReviewDemoMode.gatewayID,
text: "Watch says hello",
sentAtMs: 126,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot?.chatItems?.contains(where: { item in
item.role == "user" && item.text.contains("Watch says hello")
}) == true {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(watchService.lastSentAppSnapshot?.chatItems?.contains { item in
item.role == "user" && item.text.contains("Watch says hello")
} == true)
}
@Test func watchChatPreviewKeepsOlderReadableMessagesAfterInternalEvents() throws {
var rawMessages = try [
makeWatchChatRawMessage(
role: "assistant",
text: "Still worth reading",
timestamp: 1000),
]
for index in 0..<30 {
try rawMessages.append(
makeWatchChatRawMessage(
role: "assistant",
text: nil,
type: "toolCall",
timestamp: 2000 + Double(index)))
}
let items = NodeAppModel._test_makeWatchChatItems(from: rawMessages)
#expect(items.map(\.text) == ["Still worth reading"])
}
@Test @MainActor func watchAppCommandQueuesChatMessageWhenOperatorOffline() async {
NodeAppModel._test_resetPersistedWatchChatQueueState()
defer { NodeAppModel._test_resetPersistedWatchChatQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let gatewayID = "gateway-watch-chat-offline"
appModel._test_setConnectedGatewayID(gatewayID)
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat-offline",
command: .sendChat,
sessionKey: "main",
gatewayStableID: gatewayID,
text: "Queue this from watch",
sentAtMs: 127,
transport: "sendMessage"))
await Task.yield()
#expect(appModel._test_queuedWatchChatCommandCount() == 1)
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat-offline",
command: .sendChat,
sessionKey: "main",
gatewayStableID: gatewayID,
text: "Queue this from watch",
sentAtMs: 128,
transport: "sendMessage"))
await Task.yield()
#expect(appModel._test_queuedWatchChatCommandCount() == 1)
}
@Test @MainActor func watchAppCommandDropsChatMessageForStaleGatewaySnapshot() async {
NodeAppModel._test_resetPersistedWatchChatQueueState()
defer { NodeAppModel._test_resetPersistedWatchChatQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setConnectedGatewayID("gateway-current")
watchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat-stale-gateway",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-from-old-snapshot",
text: "Do not send to the new gateway",
sentAtMs: 128,
transport: "transferUserInfo"))
await Task.yield()
#expect(appModel._test_queuedWatchChatCommandCount() == 0)
}
@Test @MainActor func watchAppCommandRestoresQueuedChatMessageAfterModelRestart() async {
NodeAppModel._test_resetPersistedWatchChatQueueState()
defer { NodeAppModel._test_resetPersistedWatchChatQueueState() }
let gatewayID = "gateway-watch-chat-restore"
let firstWatchService = MockWatchMessagingService()
let firstAppModel = NodeAppModel(watchMessagingService: firstWatchService)
firstAppModel._test_setConnectedGatewayID(gatewayID)
firstWatchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat-restore",
command: .sendChat,
sessionKey: "main",
gatewayStableID: gatewayID,
text: "Keep this through restart",
sentAtMs: 129,
transport: "sendMessage"))
await Task.yield()
#expect(firstAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"])
let secondWatchService = MockWatchMessagingService()
let secondAppModel = NodeAppModel(watchMessagingService: secondWatchService)
secondAppModel._test_setConnectedGatewayID(gatewayID)
#expect(secondAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"])
secondWatchService.emitAppCommand(
WatchAppCommandEvent(
commandId: "watch-send-chat-restore",
command: .sendChat,
sessionKey: "main",
gatewayStableID: gatewayID,
text: "Keep this through restart",
sentAtMs: 130,
transport: "transferUserInfo"))
await Task.yield()
#expect(secondAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"])
}
@Test @MainActor func watchChatQueueScopesAndOrdersCommandsByGateway() throws {
let suiteName = "watch-chat-queue-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let first = WatchAppCommandEvent(
commandId: "watch-send-chat-gateway-a-1",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "First for gateway A",
sentAtMs: 131,
transport: "sendMessage")
let second = WatchAppCommandEvent(
commandId: "watch-send-chat-gateway-a-2",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Second for gateway A",
sentAtMs: 132,
transport: "sendMessage")
if case .queue = coordinator.ingest(first, isChatAvailable: false, gatewayStableID: "gateway-a") {
} else {
Issue.record("expected first gateway A command to queue")
}
if case .queue = coordinator.ingest(second, isChatAvailable: false, gatewayStableID: "gateway-a") {
} else {
Issue.record("expected second gateway A command to queue")
}
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
coordinator.removeQueuedCommand(
commandId: "watch-send-chat-gateway-a-1",
gatewayStableID: "gateway-b")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-1")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-1")
coordinator.removeQueuedCommand(
commandId: "watch-send-chat-gateway-a-1",
gatewayStableID: "gateway-a")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-2")
}
@Test @MainActor func watchChatRequeueKeepsOriginalGatewayOwner() throws {
let suiteName = "watch-chat-requeue-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let event = WatchAppCommandEvent(
commandId: "watch-send-chat-retry-gateway-a",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Retry for gateway A",
sentAtMs: 133,
transport: "sendMessage")
coordinator.requeueFront(event, gatewayStableID: event.gatewayStableID)
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-retry-gateway-a")
}
@Test @MainActor func watchChatRestoreBackfillsGatewayOwnerIntoLegacyQueuedEvent() throws {
let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let legacyQueueJSON = """
[
{
"gatewayStableID": "gateway-a",
"event": {
"commandId": "watch-send-chat-legacy",
"command": "send-chat",
"sessionKey": "main",
"text": "Legacy queued text",
"sentAtMs": 134,
"transport": "transferUserInfo"
}
}
]
"""
defaults.set(
Data(legacyQueueJSON.utf8),
forKey: "watch.chat.command.queue.v1")
let coordinator = WatchChatCoordinator(defaults: defaults)
let restored = coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")
#expect(restored?.commandId == "watch-send-chat-legacy")
#expect(restored?.gatewayStableID == "gateway-a")
}
@Test @MainActor func watchChatCommandDedupingKeepsOnlyRecentForwardedCommands() throws {
let suiteName = "watch-chat-recent-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
for index in 0..<140 {
let event = WatchAppCommandEvent(
commandId: "watch-forward-\(index)",
command: .sendChat,
sessionKey: "main",
gatewayStableID: nil,
text: "Message \(index)",
sentAtMs: index,
transport: "sendMessage")
if case .forward = coordinator.ingest(
event,
isChatAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected forwarded command \(index)")
}
}
let oldestEvent = WatchAppCommandEvent(
commandId: "watch-forward-0",
command: .sendChat,
sessionKey: "main",
gatewayStableID: nil,
text: "Message 0 again",
sentAtMs: 999,
transport: "sendMessage")
if case .forward = coordinator.ingest(
oldestEvent,
isChatAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected oldest forwarded command to age out of dedupe")
}
let recentEvent = WatchAppCommandEvent(
commandId: "watch-forward-139",
command: .sendChat,
sessionKey: "main",
gatewayStableID: nil,
text: "Message 139 again",
sentAtMs: 1000,
transport: "sendMessage")
if case .deduped = coordinator.ingest(
recentEvent,
isChatAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected recent forwarded command to stay deduped")
}
}
@Test @MainActor func watchChatCommandDedupingKeepsDeliveredQueuedCommandsRecent() throws {
let suiteName = "watch-chat-delivered-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
for index in 0..<140 {
let event = WatchAppCommandEvent(
commandId: "watch-queued-\(index)",
command: .sendChat,
sessionKey: "main",
gatewayStableID: nil,
text: "Queued \(index)",
sentAtMs: index,
transport: "transferUserInfo")
if case .queue = coordinator.ingest(
event,
isChatAvailable: false,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected queued command \(index)")
}
}
coordinator.removeQueuedCommand(
commandId: "watch-queued-0",
gatewayStableID: "gateway-a")
let duplicateDeliveredEvent = WatchAppCommandEvent(
commandId: "watch-queued-0",
command: .sendChat,
sessionKey: "main",
gatewayStableID: nil,
text: "Duplicate after delivery",
sentAtMs: 999,
transport: "transferUserInfo")
if case .deduped = coordinator.ingest(
duplicateDeliveredEvent,
isChatAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected delivered queued command to stay deduped")
}
}
@Test @MainActor func pendingWatchRecoveryIDsAreIncludedWithoutDeliveredNotifications() async {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }

View File

@@ -90,10 +90,11 @@ Pinned iOS version `2026.4.10` maps to:
- prepares App Store distribution signing and bundle settings against the pinned iOS version
- `scripts/ios-release-signing.mjs`
- validates the checked-in App Store signing manifest
- creates or verifies Developer Portal bundle IDs, capabilities, certificates, and profiles through `asc`
- syncs encrypted signing assets with the private shared signing repo
- renders the temporary release xcconfig profile pins
- `apps/ios/fastlane/Fastfile`
- resolves version metadata from the pinned iOS helper
- creates or verifies Developer Portal bundle IDs/services through Fastlane `produce`
- syncs encrypted App Store signing assets with Fastlane `match`
- increments App Store Connect build numbers for the pinned short version
- uploads screenshots and release notes before archiving a release build

View File

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

View File

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "openclaw-icon.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -42,6 +42,15 @@ struct OpenClawWatchApp: App {
},
onRefreshExecApprovalReview: {
self.refreshExecApprovalReview(force: true)
},
onRefreshAppSnapshot: {
self.refreshAppSnapshot()
},
onAppCommand: { command in
self.sendAppCommand(command)
},
onSendChatMessage: { text in
self.sendChatMessage(text)
})
.task {
if OpenClawWatchApp.isScreenshotMode {
@@ -53,17 +62,57 @@ struct OpenClawWatchApp: App {
receiver.activate()
self.receiver = receiver
}
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
}
.onChange(of: self.scenePhase) { _, newPhase in
guard newPhase == .active else { return }
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
}
}
}
private func refreshAppSnapshot() {
guard let receiver else { return }
self.inboxStore.markAppSnapshotRequestStarted()
Task { @MainActor in
let result = await receiver.requestAppSnapshot()
self.inboxStore.markAppSnapshotRequestResult(result)
}
}
private func sendAppCommand(_ command: WatchAppCommand) {
guard let receiver else { return }
let message = self.inboxStore.makeAppCommand(command)
self.inboxStore.markAppCommandSending(command)
Task { @MainActor in
let result = await receiver.sendAppCommand(message)
self.inboxStore.markAppCommandResult(result, command: command)
}
}
private func sendChatMessage(_ text: String) {
guard let receiver else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard self.inboxStore.hasGatewayTaggedAppSnapshot else {
self.inboxStore.markAppCommandBlocked(.sendChat, reason: "refreshing iPhone state")
self.refreshAppSnapshot()
return
}
let message = self.inboxStore.makeAppCommand(.sendChat, text: trimmed)
self.inboxStore.markAppCommandSending(.sendChat)
Task { @MainActor in
let result = await receiver.sendAppCommand(message)
self.inboxStore.markAppCommandResult(result, command: .sendChat)
try? await Task.sleep(nanoseconds: 900_000_000)
self.refreshAppSnapshot()
}
}
private func refreshExecApprovalReview(force: Bool = false) {
guard let receiver = self.receiver else { return }
guard let receiver else { return }
guard force || self.inboxStore.shouldAutoRequestExecApprovalSnapshot else { return }
self.execApprovalRefreshTask?.cancel()
@@ -93,28 +142,42 @@ struct OpenClawWatchApp: App {
@MainActor
extension WatchInboxStore {
fileprivate func configureScreenshotFixture() {
let sentAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.greetingTextOverride = "Good morning"
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
approvals: [],
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
sentAtMs: sentAtMs,
snapshotId: nil),
transport: "screenshot")
self.consume(
message: WatchNotifyMessage(
id: "watch-screenshot-quick-reply",
title: "Molty request",
body: "Molty Gateway checklist ready.",
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
promptId: "watch-screenshot-prompt",
appSnapshot: WatchAppSnapshotMessage(
gatewayStatusText: "Connected",
gatewayConnected: true,
agentName: "Molty",
agentAvatarURL: nil,
agentAvatarText: "M",
sessionKey: "watch-screenshot-session",
kind: "release-checklist",
details: nil,
expiresAtMs: nil,
risk: "medium",
actions: [
WatchPromptAction(id: "approve", label: "Approve", style: nil),
WatchPromptAction(id: "later", label: "Later", style: "cancel"),
]),
transport: "screenshot")
gatewayStableID: "watch-screenshot-gateway",
talkStatusText: "Ready",
talkEnabled: true,
talkListening: false,
talkSpeaking: false,
pendingApprovalCount: 0,
chatItems: [
WatchChatItem(
id: "watch-screenshot-user-chat",
role: "user",
text: "What's on deck?",
timestampMs: sentAtMs - 90000),
WatchChatItem(
id: "watch-screenshot-molty-chat",
role: "assistant",
text: "Gateway is online and ready.",
timestampMs: sentAtMs - 30000),
],
chatStatusText: "Live gateway conversation",
sentAtMs: sentAtMs,
snapshotId: "watch-screenshot-now-face"))
}
}

View File

@@ -35,13 +35,13 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
func activate() {
guard let session = self.session else { return }
guard let session else { return }
session.delegate = self
session.activate()
}
private func ensureActivated() async {
guard let session = self.session else { return }
guard let session else { return }
if session.activationState == .activated {
return
}
@@ -56,7 +56,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
func requestExecApprovalSnapshot() async {
await self.ensureActivated()
guard let session = self.session else { return }
guard let session else { return }
let request = WatchExecApprovalSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
@@ -72,9 +72,25 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
_ = session.transferUserInfo(payload)
}
func requestAppSnapshot() async -> WatchReplySendResult {
await self.ensureActivated()
guard let session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
transport: "none",
errorMessage: "watch session unavailable")
}
let request = WatchAppSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
let payload = Self.encodeAppSnapshotRequestPayload(request)
return await self.sendPayload(payload, session: session)
}
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
await self.ensureActivated()
guard let session = self.session else {
guard let session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
@@ -111,7 +127,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
await self.ensureActivated()
guard let session = self.session else {
guard let session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
@@ -128,6 +144,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
return await self.sendPayload(payload, session: session)
}
func sendAppCommand(_ message: WatchAppCommandMessage) async -> WatchReplySendResult {
await self.ensureActivated()
guard let session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
transport: "none",
errorMessage: "watch session unavailable")
}
return await self.sendPayload(Self.encodeAppCommandPayload(message), session: session)
}
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
if session.isReachable {
do {
@@ -364,6 +392,121 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
snapshotId: snapshotId)
}
private static func parseAppSnapshotPayload(_ payload: [String: Any]) -> WatchAppSnapshotMessage? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.appSnapshot.rawValue
else {
return nil
}
let gatewayStatusText = (payload["gatewayStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentName = (payload["agentName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentAvatarURL = (payload["agentAvatarUrl"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let agentAvatarText = (payload["agentAvatarText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let talkStatusText = (payload["talkStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
let chatStatusText = (payload["chatStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchAppSnapshotMessage(
gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText,
gatewayConnected: Self.boolValue(payload["gatewayConnected"]),
agentName: agentName.isEmpty ? "Main" : agentName,
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText,
talkEnabled: Self.boolValue(payload["talkEnabled"]),
talkListening: Self.boolValue(payload["talkListening"]),
talkSpeaking: Self.boolValue(payload["talkSpeaking"]),
pendingApprovalCount: max(0, pendingApprovalCount),
chatItems: chatItems,
chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
private static func parseChatItem(_ item: Any) -> WatchChatItem? {
guard let dict = item as? [String: Any] else { return nil }
guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty
else {
return nil
}
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? Int) ?? (dict["timestampMs"] as? NSNumber)?.intValue
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
text: text,
timestampMs: timestampMs)
}
private static func boolValue(_ value: Any?) -> Bool {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func encodeAppSnapshotRequestPayload(
_ request: WatchAppSnapshotRequestMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.appSnapshotRequest.rawValue,
"requestId": request.requestId,
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeAppCommandPayload(_ message: WatchAppCommandMessage) -> [String: Any] {
var payload: [String: Any] = [
"type": WatchPayloadType.appCommand.rawValue,
"command": message.command.rawValue,
"commandId": message.commandId,
]
if let sessionKey = message.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
!sessionKey.isEmpty
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
payload["gatewayStableID"] = gatewayStableID
}
if let text = message.text?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
{
payload["text"] = text
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeSnapshotRequestPayload(
_ request: WatchExecApprovalSnapshotRequestMessage) -> [String: Any]
{
@@ -395,10 +538,15 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
extension WatchConnectivityReceiver: WCSessionDelegate {
func session(
_: WCSession,
activationDidCompleteWith _: WCSessionActivationState,
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error _: (any Error)?)
{
if activationState == .activated, !session.receivedApplicationContext.isEmpty {
self.consumeIncomingPayload(
session.receivedApplicationContext,
transport: "receivedApplicationContext")
}
Task {
await self.requestExecApprovalSnapshot()
}
@@ -454,6 +602,12 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
Task { @MainActor in
self.store.consume(execApprovalSnapshot: snapshot, transport: transport)
}
return
}
if let snapshot = Self.parseAppSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(appSnapshot: snapshot)
}
}
}
}

View File

@@ -6,6 +6,9 @@ import WatchKit
enum WatchPayloadType: String, Codable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case appSnapshot = "watch.app.snapshot"
case appSnapshotRequest = "watch.app.snapshotRequest"
case appCommand = "watch.app.command"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
@@ -83,6 +86,54 @@ struct WatchExecApprovalResolveMessage: Codable, Equatable {
var sentAtMs: Int?
}
struct WatchAppSnapshotMessage: Codable, Equatable {
var gatewayStatusText: String
var gatewayConnected: Bool
var agentName: String
var agentAvatarURL: String?
var agentAvatarText: String?
var sessionKey: String
var gatewayStableID: String?
var talkStatusText: String
var talkEnabled: Bool
var talkListening: Bool
var talkSpeaking: Bool
var pendingApprovalCount: Int
var chatItems: [WatchChatItem]?
var chatStatusText: String?
var sentAtMs: Int?
var snapshotId: String?
}
struct WatchChatItem: Codable, Equatable, Identifiable {
var id: String
var role: String
var text: String
var timestampMs: Int?
}
struct WatchAppSnapshotRequestMessage: Codable, Equatable {
var requestId: String
var sentAtMs: Int?
}
enum WatchAppCommand: String, Codable, Equatable {
case refresh
case openChat = "open-chat"
case sendChat = "send-chat"
case startTalk = "start-talk"
case stopTalk = "stop-talk"
}
struct WatchAppCommandMessage: Codable, Equatable {
var command: WatchAppCommand
var commandId: String
var sessionKey: String?
var gatewayStableID: String?
var text: String?
var sentAtMs: Int?
}
struct WatchPromptAction: Codable, Equatable, Identifiable {
var id: String
var label: String
@@ -138,6 +189,10 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var lastExecApprovalSnapshotID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var appSnapshot: WatchAppSnapshotMessage?
var appSnapshotUpdatedAt: Date?
var appSnapshotStatusText: String?
var appCommandStatusText: String?
}
private static let persistedStateKey = "watch.inbox.state.v2"
@@ -163,6 +218,11 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var selectedExecApprovalID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var appSnapshot: WatchAppSnapshotMessage?
var appSnapshotUpdatedAt: Date?
var appSnapshotStatusText: String?
var appCommandStatusText: String?
var greetingTextOverride: String?
var isExecApprovalReviewLoading = false
var execApprovalReviewStatusText: String?
var execApprovalReviewStatusAt: Date?
@@ -197,7 +257,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var activeExecApproval: WatchExecApprovalRecord? {
if let selectedExecApprovalID,
let selected = self.execApprovals.first(where: { $0.id == selectedExecApprovalID })
let selected = execApprovals.first(where: { $0.id == selectedExecApprovalID })
{
return selected
}
@@ -220,6 +280,35 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true)
}
var hasAppSnapshot: Bool {
self.appSnapshot != nil
}
var hasMessagePrompt: Bool {
self.title != Self.defaultTitle
|| self.body != Self.defaultBody
|| !self.actions.isEmpty
}
var gatewaySummaryText: String {
guard let appSnapshot else { return "Waiting for iPhone" }
return appSnapshot.gatewayConnected ? "Connected" : appSnapshot.gatewayStatusText
}
var talkSummaryText: String {
guard let appSnapshot else { return "Not synced" }
if appSnapshot.talkListening {
return "Listening"
}
if appSnapshot.talkSpeaking {
return "Speaking"
}
if appSnapshot.talkEnabled {
return appSnapshot.talkStatusText.isEmpty ? "Ready" : appSnapshot.talkStatusText
}
return "Off"
}
func beginExecApprovalReviewLoading() {
guard self.execApprovals.isEmpty else {
self.markExecApprovalReviewLoaded()
@@ -312,12 +401,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
transport: String)
{
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
if let snapshotID, !snapshotID.isEmpty, snapshotID == self.lastExecApprovalSnapshotID {
if let snapshotID, !snapshotID.isEmpty, snapshotID == lastExecApprovalSnapshotID {
return
}
let existingRecordsByID = Dictionary(
uniqueKeysWithValues: self.execApprovals.map { ($0.id, $0) })
uniqueKeysWithValues: execApprovals.map { ($0.id, $0) })
self.execApprovals = message.approvals.map { approval in
self.mergedExecApprovalRecord(
approval: approval,
@@ -330,14 +419,90 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
} else if self.selectedExecApprovalID == nil {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
} else if selectedExecApprovalID == nil {
selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.markExecApprovalReviewLoaded()
self.persistState()
}
func consume(appSnapshot message: WatchAppSnapshotMessage) {
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
if let snapshotID, !snapshotID.isEmpty, snapshotID == appSnapshot?.snapshotId {
return
}
var merged = message
if merged.chatItems == nil {
merged.chatItems = self.appSnapshot?.chatItems
}
if merged.chatStatusText == nil {
merged.chatStatusText = self.appSnapshot?.chatStatusText
}
self.appSnapshot = merged
self.appSnapshotUpdatedAt = Date()
self.appSnapshotStatusText = nil
self.persistState()
}
func markAppSnapshotRequestStarted() {
self.appSnapshotStatusText = "Refreshing from iPhone…"
self.persistState()
}
func markAppSnapshotRequestResult(_ result: WatchReplySendResult) {
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.appSnapshotStatusText = "Refresh failed: \(errorMessage)"
} else if result.deliveredImmediately {
self.appSnapshotStatusText = "Refresh requested"
} else if result.queuedForDelivery {
self.appSnapshotStatusText = "Refresh queued"
} else {
self.appSnapshotStatusText = nil
}
self.persistState()
}
func makeAppCommand(_ command: WatchAppCommand, text: String? = nil) -> WatchAppCommandMessage {
let snapshotSessionKey = self.appSnapshot?.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchAppCommandMessage(
command: command,
commandId: UUID().uuidString,
sessionKey: (snapshotSessionKey?.isEmpty == false) ? snapshotSessionKey : self.sessionKey,
gatewayStableID: self.appSnapshot?.gatewayStableID,
text: text,
sentAtMs: Self.nowMs())
}
var hasGatewayTaggedAppSnapshot: Bool {
let gatewayStableID = self.appSnapshot?.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !gatewayStableID.isEmpty
}
func markAppCommandSending(_ command: WatchAppCommand) {
self.appCommandStatusText = "Sending \(Self.commandLabel(command))"
self.persistState()
}
func markAppCommandBlocked(_ command: WatchAppCommand, reason: String) {
self.appCommandStatusText = "\(Self.commandLabel(command)): \(reason)"
self.persistState()
}
func markAppCommandResult(_ result: WatchReplySendResult, command: WatchAppCommand) {
let label = Self.commandLabel(command)
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.appCommandStatusText = "\(label) failed: \(errorMessage)"
} else if result.deliveredImmediately {
self.appCommandStatusText = "\(label): sent"
} else if result.queuedForDelivery {
self.appCommandStatusText = "\(label): queued"
} else {
self.appCommandStatusText = "\(label): sent"
}
self.persistState()
}
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
self.removeExecApproval(id: message.approvalId)
let statusText = switch message.decision {
@@ -381,7 +546,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
func markExecApprovalSending(approvalId: String, decision: WatchExecApprovalDecision) {
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
guard let index = execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
self.execApprovals[index].isResolving = true
self.execApprovals[index].pendingDecision = decision
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))"
@@ -394,7 +559,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
decision: WatchExecApprovalDecision,
result: WatchReplySendResult)
{
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
guard let index = execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.execApprovals[index].isResolving = false
self.execApprovals[index].statusText = "Failed: \(errorMessage)"
@@ -419,7 +584,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
keepSelectionIfPossible: Bool,
resetResolvingState: Bool = false)
{
if let index = self.execApprovals.firstIndex(where: { $0.id == approval.id }) {
if let index = execApprovals.firstIndex(where: { $0.id == approval.id }) {
self.execApprovals[index] = self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
@@ -486,7 +651,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
private func restorePersistedState() {
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
guard let data = defaults.data(forKey: Self.persistedStateKey),
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
else {
return
@@ -511,30 +676,38 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
self.appSnapshot = state.appSnapshot
self.appSnapshotUpdatedAt = state.appSnapshotUpdatedAt
self.appSnapshotStatusText = state.appSnapshotStatusText
self.appCommandStatusText = state.appCommandStatusText
}
private func persistState() {
let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date()
let state = PersistedState(
title: self.title,
body: self.body,
transport: self.transport,
title: title,
body: body,
transport: transport,
updatedAt: updatedAt,
lastDeliveryKey: self.lastDeliveryKey,
promptId: self.promptId,
sessionKey: self.sessionKey,
kind: self.kind,
details: self.details,
expiresAtMs: self.expiresAtMs,
risk: self.risk,
actions: self.actions,
replyStatusText: self.replyStatusText,
replyStatusAt: self.replyStatusAt,
execApprovals: self.execApprovals,
selectedExecApprovalID: self.selectedExecApprovalID,
lastExecApprovalSnapshotID: self.lastExecApprovalSnapshotID,
lastExecApprovalOutcomeText: self.lastExecApprovalOutcomeText,
lastExecApprovalOutcomeAt: self.lastExecApprovalOutcomeAt)
lastDeliveryKey: lastDeliveryKey,
promptId: promptId,
sessionKey: sessionKey,
kind: kind,
details: details,
expiresAtMs: expiresAtMs,
risk: risk,
actions: actions,
replyStatusText: replyStatusText,
replyStatusAt: replyStatusAt,
execApprovals: execApprovals,
selectedExecApprovalID: selectedExecApprovalID,
lastExecApprovalSnapshotID: lastExecApprovalSnapshotID,
lastExecApprovalOutcomeText: lastExecApprovalOutcomeText,
lastExecApprovalOutcomeAt: lastExecApprovalOutcomeAt,
appSnapshot: appSnapshot,
appSnapshotUpdatedAt: appSnapshotUpdatedAt,
appSnapshotStatusText: appSnapshotStatusText,
appCommandStatusText: appCommandStatusText)
guard let data = try? JSONEncoder().encode(state) else { return }
self.defaults.set(data, forKey: Self.persistedStateKey)
}
@@ -627,6 +800,21 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
}
private static func commandLabel(_ command: WatchAppCommand) -> String {
switch command {
case .refresh:
"Refresh"
case .openChat:
"Open Chat"
case .sendChat:
"Chat"
case .startTalk:
"Start Talk"
case .stopTalk:
"Stop Talk"
}
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,26 @@
# App Store Connect API key (pick one approach)
#
# Recommended (use the downloaded .p8 directly):
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX
# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# APP_STORE_CONNECT_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
#
# Or (JSON key file):
# APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json
#
# Or:
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_CONTENT=BASE64_P8_CONTENT
# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX
# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# APP_STORE_CONNECT_KEY_CONTENT=BASE64_P8_CONTENT
#
# Or (macOS Keychain, recommended for maintainer machines):
# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX
# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key
# APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=your-macos-user
# Fastlane match signing repo encryption
# MATCH_PASSWORD=...
# Code signing
# IOS_DEVELOPMENT_TEAM=XXXXXXXXXX

View File

@@ -4,12 +4,14 @@ app_identifier("ai.openclawfoundation.app")
# Provide either:
# - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended)
# or:
# - ASC_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with ASC_KEY_ID and ASC_ISSUER_ID
# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content)
# - ASC_KEY_ID and ASC_ISSUER_ID plus Keychain fallback:
# ASC_KEYCHAIN_SERVICE (default: openclaw-asc-key)
# ASC_KEYCHAIN_ACCOUNT (default: USER/LOGNAME)
# - APP_STORE_CONNECT_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with
# APP_STORE_CONNECT_KEY_ID and APP_STORE_CONNECT_ISSUER_ID
# - APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, and
# APP_STORE_CONNECT_KEY_CONTENT (base64 or raw p8 content)
# - APP_STORE_CONNECT_KEY_ID and APP_STORE_CONNECT_ISSUER_ID plus Keychain fallback:
# APP_STORE_CONNECT_KEYCHAIN_SERVICE (default: openclaw-app-store-connect-key)
# APP_STORE_CONNECT_KEYCHAIN_ACCOUNT (default: USER/LOGNAME)
#
# Optional deliver app lookup overrides:
# - ASC_APP_IDENTIFIER (bundle ID)
# - ASC_APP_ID (numeric App Store Connect app ID)
# - APP_STORE_CONNECT_APP_IDENTIFIER (bundle ID)
# - APP_STORE_CONNECT_APP_ID (numeric App Store Connect app ID)

View File

@@ -9,6 +9,7 @@ require "cgi"
default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
@@ -18,6 +19,17 @@ REQUIRED_SCREENSHOT_FAMILIES = {
"iPhone" => /iPhone/,
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
}.freeze
PUBLIC_METADATA_FILENAMES = [
"description.txt",
"keywords.txt",
"marketing_url.txt",
"name.txt",
"privacy_url.txt",
"promotional_text.txt",
"release_notes.txt",
"subtitle.txt",
"support_url.txt"
].freeze
def load_env_file(path)
return unless File.exist?(path)
@@ -273,7 +285,7 @@ def capture_watch_screenshot
device_name = device.fetch("name")
udid = device.fetch("udid")
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
output_path = File.join(output_dir, "#{device_name}-01-quick-reply.png")
output_path = File.join(output_dir, "#{device_name}-01-now-face.png")
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
@@ -349,7 +361,7 @@ def maybe_decode_hex_keychain_secret(value)
beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret
endPemMarker = %w[END PRIVATE KEY].join(" ")
if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker)
UI.message("Decoded hex-encoded ASC key content from Keychain.")
UI.message("Decoded hex-encoded App Store Connect key content from Keychain.")
return decoded
end
rescue StandardError
@@ -359,11 +371,11 @@ def maybe_decode_hex_keychain_secret(value)
candidate
end
def read_asc_key_content_from_keychain
service = ENV["ASC_KEYCHAIN_SERVICE"]
service = "openclaw-asc-key" unless env_present?(service)
def read_app_store_connect_key_content_from_keychain
service = ENV["APP_STORE_CONNECT_KEYCHAIN_SERVICE"]
service = DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE unless env_present?(service)
account = ENV["ASC_KEYCHAIN_ACCOUNT"]
account = ENV["APP_STORE_CONNECT_KEYCHAIN_ACCOUNT"]
account = ENV["USER"] unless env_present?(account)
account = ENV["LOGNAME"] unless env_present?(account)
return nil unless env_present?(account)
@@ -385,7 +397,7 @@ def read_asc_key_content_from_keychain
key_content = maybe_decode_hex_keychain_secret(key_content)
return nil unless env_present?(key_content)
UI.message("Loaded ASC key content from Keychain service '#{service}' (account '#{account}').")
UI.message("Loaded App Store Connect key content from Keychain service '#{service}' (account '#{account}').")
key_content
rescue Errno::ENOENT
nil
@@ -423,8 +435,16 @@ def app_store_signing_manifest
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end
def app_store_signing_targets
app_store_signing_manifest.fetch("targets")
end
def app_store_bundle_identifiers
app_store_signing_targets.map { |target| target.fetch("bundleId") }
end
def app_store_provisioning_profiles
app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles|
app_store_signing_targets.each_with_object({}) do |target, profiles|
profiles[target.fetch("bundleId")] = target.fetch("profileName")
end
end
@@ -467,8 +487,114 @@ def write_app_store_export_options(path)
PLIST
end
def produce_services_for_target(target)
services = {}
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
services
end
def ensure_release_bundle_ids!
manifest = app_store_signing_manifest
app_store_signing_targets.each do |target|
options = {
app_identifier: target.fetch("bundleId"),
app_name: target.fetch("displayName"),
skip_itc: true,
team_id: manifest.fetch("teamId")
}
services = produce_services_for_target(target)
options[:enable_services] = services unless services.empty?
produce(**options)
unless services.empty?
modify_services(
app_identifier: target.fetch("bundleId"),
services: services,
team_id: manifest.fetch("teamId")
)
end
end
end
def app_store_match_options(readonly:, target:, api_key:)
manifest = app_store_signing_manifest
options = {
type: manifest.fetch("profileType"),
app_identifier: target.fetch("bundleId"),
profile_name: target.fetch("profileName"),
git_url: manifest.fetch("signingRepo"),
git_branch: manifest.fetch("signingBranch"),
platform: "ios",
team_id: manifest.fetch("teamId"),
readonly: readonly
}
options[:api_key] = api_key if api_key
options
end
def validate_match_profile_mapping!(target)
bundle_id = target.fetch("bundleId")
expected_profile_name = target.fetch("profileName")
actual = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {}
actual_profile_name = actual[bundle_id]
return if actual_profile_name == expected_profile_name
UI.user_error!(
"Fastlane match did not resolve the pinned App Store profile for #{bundle_id}: expected #{expected_profile_name}, got #{actual_profile_name || "no match output"}"
)
end
def match_profile_env_key(target, suffix)
["sigh", target.fetch("bundleId"), app_store_signing_manifest.fetch("profileType"), suffix].join("_")
end
def profile_plist_value(profile_path, key_path)
Tempfile.create(["openclaw-profile", ".plist"]) do |file|
stdout, stderr, status = Open3.capture3("security", "cms", "-D", "-i", profile_path)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to decode provisioning profile #{profile_path}: #{detail}")
end
file.write(stdout)
file.flush
value, _plist_stderr, plist_status = Open3.capture3("/usr/libexec/PlistBuddy", "-c", "Print:#{key_path}", file.path)
return nil unless plist_status.success?
value.to_s.strip
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
profile_path = ENV[match_profile_env_key(target, "profile-path")]
UI.user_error!("Fastlane match did not expose an installed profile path for #{target.fetch("bundleId")}.") unless env_present?(profile_path)
if capabilities.include?("PUSH_NOTIFICATIONS")
aps_environment = profile_plist_value(profile_path, "Entitlements:aps-environment")
if aps_environment != "production"
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production push entitlement; expected aps-environment=production, got #{aps_environment || "missing"}."
)
end
end
end
def sync_app_store_signing!(readonly:)
api_key = readonly ? nil : app_store_connect_api_key_config
app_store_signing_targets.each do |target|
match(**app_store_match_options(readonly: readonly, target: target, api_key: api_key))
validate_match_profile_mapping!(target)
validate_match_profile_capabilities!(target)
end
end
def release_signing_check!
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"]))
sync_app_store_signing!(readonly: true)
end
def release_notes_path
@@ -486,6 +612,19 @@ def release_notes_metadata_path
temp_root
end
def public_metadata_path
source = File.join(__dir__, "metadata")
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
Dir.children(source).each do |entry|
source_entry = File.join(source, entry)
next unless File.directory?(source_entry)
next unless PUBLIC_METADATA_FILENAMES.any? { |filename| File.exist?(File.join(source_entry, filename)) }
FileUtils.cp_r(source_entry, File.join(temp_root, entry))
end
temp_root
end
def read_ios_version_metadata
script_path = File.join(repo_root, "scripts", "ios-version.ts")
stdout, stderr, status = Open3.capture3(
@@ -563,7 +702,7 @@ def resolve_release_build_number(api_key:, short_version:)
next_build.to_s
end
def release_build_number_needs_asc_auth?
def release_build_number_needs_app_store_connect_auth?
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
!env_present?(explicit)
end
@@ -640,58 +779,58 @@ def build_app_store_release(context)
}
end
platform :ios do
private_lane :asc_api_key do
load_env_file(File.join(__dir__, ".env"))
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
clear_empty_env_var("ASC_KEY_PATH")
clear_empty_env_var("ASC_KEY_CONTENT")
def app_store_connect_api_key_config
load_env_file(File.join(__dir__, ".env"))
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
clear_empty_env_var("APP_STORE_CONNECT_KEY_PATH")
clear_empty_env_var("APP_STORE_CONNECT_KEY_CONTENT")
api_key = nil
api_key = nil
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
if env_present?(key_path)
api_key = app_store_connect_api_key(path: key_path)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
if env_present?(key_path)
api_key = app_store_connect_api_key(path: key_path)
else
p8_path = ENV["APP_STORE_CONNECT_KEY_PATH"]
if env_present?(p8_path)
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
UI.user_error!("Missing APP_STORE_CONNECT_KEY_ID or APP_STORE_CONNECT_ISSUER_ID for APP_STORE_CONNECT_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) }
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: p8_path
)
else
p8_path = ENV["ASC_KEY_PATH"]
if env_present?(p8_path)
key_id = ENV["ASC_KEY_ID"]
issuer_id = ENV["ASC_ISSUER_ID"]
UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) }
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
key_content = ENV["APP_STORE_CONNECT_KEY_CONTENT"]
key_content = read_app_store_connect_key_content_from_keychain unless env_present?(key_content)
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: p8_path
)
else
key_id = ENV["ASC_KEY_ID"]
issuer_id = ENV["ASC_ISSUER_ID"]
key_content = ENV["ASC_KEY_CONTENT"]
key_content = read_asc_key_content_from_keychain unless env_present?(key_content)
UI.user_error!(
"Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), APP_STORE_CONNECT_KEY_PATH (p8), or APP_STORE_CONNECT_KEY_ID/APP_STORE_CONNECT_ISSUER_ID with APP_STORE_CONNECT_KEY_CONTENT (or Keychain via APP_STORE_CONNECT_KEYCHAIN_SERVICE/APP_STORE_CONNECT_KEYCHAIN_ACCOUNT)."
) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }
UI.user_error!(
"Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), ASC_KEY_PATH (p8), or ASC_KEY_ID/ASC_ISSUER_ID with ASC_KEY_CONTENT (or Keychain via ASC_KEYCHAIN_SERVICE/ASC_KEYCHAIN_ACCOUNT)."
) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: is_base64
)
end
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: is_base64
)
end
api_key
end
api_key
end
platform :ios do
private_lane :prepare_app_store_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || release_build_number_needs_asc_auth?
api_key = needs_api_key ? asc_api_key : nil
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?
api_key = needs_api_key ? app_store_connect_api_key_config : nil
sync_ios_versioning!
version_metadata = read_ios_version_metadata
version = version_metadata[:version]
@@ -708,6 +847,37 @@ platform :ios do
}
end
desc "Print the App Store signing plan"
lane :signing_plan do
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"]))
end
desc "Check local App Store signing assets through Fastlane match"
lane :signing_check do
sync_app_store_signing!(readonly: true)
UI.success("Fastlane match App Store signing assets are available locally.")
end
desc "Create Developer Portal bundle IDs/services and sync App Store signing assets"
lane :signing_setup do
ensure_release_bundle_ids!
sync_app_store_signing!(readonly: false)
UI.success("Fastlane App Store signing setup is complete.")
end
desc "Pull encrypted App Store signing assets from the shared Fastlane match repo"
lane :signing_sync_pull do
sync_app_store_signing!(readonly: true)
UI.success("Pulled Fastlane match App Store signing assets.")
end
desc "Create or refresh encrypted App Store signing assets in the shared Fastlane match repo"
lane :signing_sync_push do
ensure_release_bundle_ids!
sync_app_store_signing!(readonly: false)
UI.success("Pushed Fastlane match App Store signing assets.")
end
desc "Build an App Store distribution archive locally without uploading"
lane :app_store_archive do
context = prepare_app_store_context(require_api_key: false)
@@ -765,10 +935,10 @@ platform :ios do
lane :metadata do
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = asc_api_key
api_key = app_store_connect_api_key_config
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["ASC_APP_IDENTIFIER"]
app_id = ENV["ASC_APP_ID"]
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
@@ -780,7 +950,7 @@ platform :ios do
validate_required_screenshots!(paths)
end
metadata_path = File.join(__dir__, "metadata")
metadata_path = public_metadata_path
skip_metadata = ENV["DELIVER_METADATA"] != "1"
if release_notes_upload_requested? && skip_metadata
metadata_path = release_notes_metadata_path
@@ -849,7 +1019,7 @@ platform :ios do
desc "Validate App Store Connect API auth"
lane :auth_check do
asc_api_key
app_store_connect_api_key_config
UI.success("App Store Connect API auth loaded successfully.")
end
end

View File

@@ -14,7 +14,7 @@ Create an App Store Connect API key:
Recommended (macOS): store the private key in Keychain and write non-secret vars:
```bash
scripts/ios-asc-keychain-setup.sh \
scripts/ios-app-store-connect-keychain-setup.sh \
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
--issuer-id YOUR_ISSUER_ID \
--write-env
@@ -23,10 +23,10 @@ scripts/ios-asc-keychain-setup.sh \
This writes these auth variables in `apps/ios/fastlane/.env`:
```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID
APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID
APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key
APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional release-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
@@ -34,17 +34,17 @@ Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
ASC_APP_IDENTIFIER=ai.openclawfoundation.app
APP_STORE_CONNECT_APP_IDENTIFIER=ai.openclawfoundation.app
# or
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
```
File-based fallback (CI/non-macOS):
```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID
APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID
APP_STORE_CONNECT_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
```
Code signing variable (optional in `.env`):
@@ -55,7 +55,7 @@ IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
Tip: run `scripts/ios-team-id.sh --require-canonical` from repo root to verify the canonical OpenClaw iOS team (`FWJYW4S8P8`) is available locally. Fastlane uses the same canonical-only path when `IOS_DEVELOPMENT_TEAM` is missing, and rejects non-canonical teams for release archives.
App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`.
App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`, and Fastlane `match` owns the encrypted signing repo and branch named there.
One-time or rotation setup:
@@ -65,14 +65,16 @@ 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`.
Shared encrypted signing storage:
```bash
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
The signing repo is private and encrypted. Store `ASC_MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` writes decrypted assets under `apps/ios/build/signing/`; import the distribution certificate/private key into Keychain before archiving.
The signing repo is private and encrypted. Store `MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` uses Fastlane `match` to decrypt, install profiles, and import the distribution signing identity into the local Keychain.
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
@@ -83,12 +85,12 @@ cd apps/ios
fastlane ios auth_check
```
ASC auth is only required when:
App Store Connect API auth is required when:
- uploading to App Store Connect
- auto-resolving the next build number from App Store Connect
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need ASC auth.
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need App Store Connect API auth.
Archive locally without upload:
@@ -119,14 +121,14 @@ fastlane ios release_upload
Maintainer recovery path for a fresh clone on the same Mac:
1. Reuse the existing Keychain-backed ASC key on that machine.
1. Reuse the existing Keychain-backed App Store Connect key on that machine.
2. Restore or recreate `apps/ios/fastlane/.env` so it contains the non-secret variables:
```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID
APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID
APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key
APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
3. Re-run auth validation:

View File

@@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
```bash
cd apps/ios
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```
@@ -31,14 +31,14 @@ DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata
The `ios metadata` lane uses App Store Connect API key auth from `apps/ios/fastlane/.env`:
- Keychain-backed (recommended on macOS):
- `ASC_KEY_ID`
- `ASC_ISSUER_ID`
- `ASC_KEYCHAIN_SERVICE` (default: `openclaw-asc-key`)
- `ASC_KEYCHAIN_ACCOUNT` (default: current user)
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_KEYCHAIN_SERVICE` (default: `openclaw-app-store-connect-key`)
- `APP_STORE_CONNECT_KEYCHAIN_ACCOUNT` (default: current user)
- File/path fallback:
- `ASC_KEY_ID`
- `ASC_ISSUER_ID`
- `ASC_KEY_PATH`
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_KEY_PATH`
Or set `APP_STORE_CONNECT_API_KEY_PATH`.
@@ -51,10 +51,6 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
- If app lookup fails in `deliver`, set one of:
- `ASC_APP_IDENTIFIER` (bundle ID)
- `ASC_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
- For first app versions, include review contact files under `metadata/review_information/`:
- `first_name.txt`
- `last_name.txt`
- `email_address.txt`
- `phone_number.txt` (E.164-ish, e.g. `+1 415 555 0100`)
- `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID)
- `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
- App Review submission is manual. Keep review contact, demo account, and reviewer notes outside this repo and enter them directly in App Store Connect when submitting for review.

View File

@@ -1 +0,0 @@
support@openclaw.ai

View File

@@ -1,3 +0,0 @@
OpenClaw normally pairs with a private Gateway. For App Review, tap Set Up Manually on the Connect Gateway screen, paste APPLE-REVIEW-DEMO in Setup Code, then tap Apply Setup Code. This enables local offline demo mode; no Gateway is required. Reviewers can also scan a QR code containing APPLE-REVIEW-DEMO.
Demo mode marks the app as connected to an Apple Review Demo Gateway and exposes the Chat, Command, Agent, Talk, and Settings surfaces without requiring a running Gateway. Live automation, realtime Talk execution, and external tool calls require pairing with a real OpenClaw Gateway.

View File

@@ -1 +0,0 @@
+1 415 555 0100

View File

@@ -289,6 +289,7 @@ targets:
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework

View File

@@ -368,7 +368,7 @@ enum ExecApprovalsStore {
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
copyfile_flags_t(COPYFILE_DATA | COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)

View File

@@ -1,41 +1,68 @@
import Foundation
private struct RootCommand {
struct RootCommand: Equatable {
var name: String
var args: [String]
}
enum RootCommandAction: Equatable {
case usage
case connect([String])
case configureRemote([String])
case discover([String])
case wizard([String])
case unknown(exitCode: Int32)
}
@main
struct OpenClawMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
switch resolveRootCommandAction(args) {
case .usage:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "configure-remote":
runConfigureRemote(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
case let .connect(commandArgs):
await runConnect(commandArgs)
case let .configureRemote(commandArgs):
runConfigureRemote(commandArgs)
case let .discover(commandArgs):
await runDiscover(commandArgs)
case let .wizard(commandArgs):
await runWizardCommand(commandArgs)
case let .unknown(exitCode):
fputs("openclaw-mac: unknown command\n", stderr)
printUsage()
exit(1)
exit(exitCode)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
func resolveRootCommandAction(_ args: [String]) -> RootCommandAction {
guard let command = parseRootCommand(args) else {
return .usage
}
switch command.name {
case "-h", "--help", "help":
return .usage
case "connect":
return .connect(command.args)
case "configure-remote":
return .configureRemote(command.args)
case "discover":
return .discover(command.args)
case "wizard":
return .wizard(command.args)
default:
return .unknown(exitCode: 1)
}
}
private func printUsage() {
print("""
openclaw-mac

View File

@@ -0,0 +1,38 @@
import Testing
@testable import OpenClawMacCLI
struct RootCommandParserTests {
@Test func `parse root command returns nil for empty args`() {
#expect(parseRootCommand([]) == nil)
}
@Test func `parse root command splits command name and args`() throws {
let command = try #require(parseRootCommand(["connect", "--json", "--timeout", "3000"]))
#expect(command.name == "connect")
#expect(command.args == ["--json", "--timeout", "3000"])
}
@Test func `help aliases resolve to usage`() {
for args in [[], ["-h"], ["--help"], ["help"]] {
#expect(resolveRootCommandAction(args) == .usage)
}
}
@Test func `known commands preserve trailing args`() {
#expect(resolveRootCommandAction(["connect", "--json"]) == .connect(["--json"]))
#expect(
resolveRootCommandAction(["configure-remote", "--ssh-target", "alice@example.com"])
== .configureRemote(["--ssh-target", "alice@example.com"]))
#expect(resolveRootCommandAction(["discover", "--include-local"]) == .discover(["--include-local"]))
#expect(resolveRootCommandAction(["wizard", "--mode", "local"]) == .wizard(["--mode", "local"]))
}
@Test func `unknown command resolves to nonzero exit action`() {
#expect(resolveRootCommandAction(["nope"]) == .unknown(exitCode: 1))
}
@Test func `command names remain case sensitive`() {
#expect(resolveRootCommandAction(["Connect"]) == .unknown(exitCode: 1))
}
}

View File

@@ -8,6 +8,9 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case appSnapshot = "watch.app.snapshot"
case appSnapshotRequest = "watch.app.snapshotRequest"
case appCommand = "watch.app.command"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
@@ -192,6 +195,129 @@ public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable
}
}
public struct OpenClawWatchChatItem: Codable, Sendable, Equatable, Identifiable {
public var id: String
public var role: String
public var text: String
public var timestampMs: Int?
public init(
id: String,
role: String,
text: String,
timestampMs: Int? = nil)
{
self.id = id
self.role = role
self.text = text
self.timestampMs = timestampMs
}
}
public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var gatewayStatusText: String
public var gatewayConnected: Bool
public var agentName: String
public var agentAvatarURL: String?
public var agentAvatarText: String?
public var sessionKey: String
public var gatewayStableID: String?
public var talkStatusText: String
public var talkEnabled: Bool
public var talkListening: Bool
public var talkSpeaking: Bool
public var pendingApprovalCount: Int
public var chatItems: [OpenClawWatchChatItem]?
public var chatStatusText: String?
public var sentAtMs: Int?
public var snapshotId: String?
public init(
gatewayStatusText: String,
gatewayConnected: Bool,
agentName: String,
agentAvatarURL: String? = nil,
agentAvatarText: String? = nil,
sessionKey: String,
gatewayStableID: String? = nil,
talkStatusText: String,
talkEnabled: Bool,
talkListening: Bool,
talkSpeaking: Bool,
pendingApprovalCount: Int,
chatItems: [OpenClawWatchChatItem]? = nil,
chatStatusText: String? = nil,
sentAtMs: Int? = nil,
snapshotId: String? = nil)
{
self.type = .appSnapshot
self.gatewayStatusText = gatewayStatusText
self.gatewayConnected = gatewayConnected
self.agentName = agentName
self.agentAvatarURL = agentAvatarURL
self.agentAvatarText = agentAvatarText
self.sessionKey = sessionKey
self.gatewayStableID = gatewayStableID
self.talkStatusText = talkStatusText
self.talkEnabled = talkEnabled
self.talkListening = talkListening
self.talkSpeaking = talkSpeaking
self.pendingApprovalCount = pendingApprovalCount
self.chatItems = chatItems
self.chatStatusText = chatStatusText
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
}
}
public struct OpenClawWatchAppSnapshotRequestMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int?
public init(requestId: String, sentAtMs: Int? = nil) {
self.type = .appSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
}
}
public enum OpenClawWatchAppCommand: String, Codable, Sendable, Equatable {
case refresh
case openChat = "open-chat"
case sendChat = "send-chat"
case startTalk = "start-talk"
case stopTalk = "stop-talk"
}
public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var command: OpenClawWatchAppCommand
public var commandId: String
public var sessionKey: String?
public var gatewayStableID: String?
public var text: String?
public var sentAtMs: Int?
public init(
command: OpenClawWatchAppCommand,
commandId: String,
sessionKey: String? = nil,
gatewayStableID: String? = nil,
text: String? = nil,
sentAtMs: Int? = nil)
{
self.type = .appCommand
self.command = command
self.commandId = commandId
self.sessionKey = sessionKey
self.gatewayStableID = gatewayStableID
self.text = text
self.sentAtMs = sentAtMs
}
}
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
public var supported: Bool
public var paired: Bool

View File

@@ -1,4 +1,4 @@
823613bb0103db76f108f940572824ab961c19e94d5d09885669066e8dbbfdbd config-baseline.json
28e51c1f60f46897d7b10635dd401d08ed6b6bc080178648c9df8aaf3fbfc171 config-baseline.core.json
b7ec57a4f38bf44677870fd9a8347be83f3f23a25a73d97931406f0eff572181 config-baseline.json
99d506f05de601e5b45c98f302650c8608d1e2bb3dcea11bf97881c1263659ac config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
e648318e223f598b661196be38e50a233917cb4e105b06f7ce9d7c759ada41ba plugin-sdk-api-baseline.json
24fe83068a2bd188f541862172d34424a6b427a3592544041e69267f8edf0f33 plugin-sdk-api-baseline.jsonl
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl

View File

@@ -1175,8 +1175,24 @@
"source": "Control UI",
"target": "Control UI"
},
{
"source": "Models CLI",
"target": "模型 CLI"
},
{
"source": "Z.AI (GLM)",
"target": "Z.AI (GLM)"
},
{
"source": "Cohere",
"target": "Cohere"
},
{
"source": "Cohere plugin",
"target": "Cohere 插件"
},
{
"source": "cohere",
"target": "cohere"
}
]

View File

@@ -422,7 +422,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients. This compatibility mode supports normal bold, italic, links, code, spoilers, and quotes, but not Bot API 10.1 rich-only blocks such as native tables, details, rich media, and formulas.
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
@@ -436,13 +436,16 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
}
```
When enabled:
- The agent is told that Telegram rich messages are available for this bot/account.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
Default: off for client compatibility. Rich messages require compatible Telegram clients; some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported. Keep this option disabled unless every client used with the bot can render them. `/status` shows whether the current Telegram session has rich messages on or off.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.

View File

@@ -164,7 +164,7 @@ handoff path over manual terminal capture.
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence operation bound.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence and inbound read-receipt operation bounds.
- Outbound sends require an active WhatsApp listener for the target account.
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).

View File

@@ -63,7 +63,7 @@ Quick rule:
fallback and do not reconstruct historic tool calls or system notices.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
default isolated `acp-bridge:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
@@ -206,7 +206,7 @@ openclaw acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
sessions; ACP defaults to an isolated `acp-bridge:<uuid>` session unless you override
the key or label.
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
@@ -309,8 +309,10 @@ In Zed, open the Agent panel and select "OpenClaw ACP" to start a thread.
## Session mapping
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
To reuse a known session, pass a session key or label:
By default, ACP bridge sessions get an isolated Gateway session key with an
`acp-bridge:` prefix. These normal-model bridge sessions are synthetic and
subject to stale-entry pruning and entry-count caps. To reuse a known session,
pass a session key or label:
- `--session <key>`: use a specific Gateway session key.
- `--session-label <label>`: resolve an existing session by label.

View File

@@ -11,13 +11,17 @@ sidebarTitle: "MCP"
`openclaw mcp` has two jobs:
- run OpenClaw as an MCP server with `openclaw mcp serve`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
- manage OpenClaw-managed outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
In other words:
- `serve` is OpenClaw acting as an MCP server
- the other subcommands are OpenClaw acting as an MCP client-side registry for MCP servers its runtimes may consume later
<Note>
`list`, `show`, `set`, and `unset` only read and write OpenClaw-managed `mcp.servers` entries in OpenClaw config. They do not include mcporter servers from `config/mcporter.json`; use `mcporter list` for that registry.
</Note>
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
## Choose the right MCP path
@@ -368,7 +372,7 @@ For broader testing context, see [Testing](/help/testing).
This is the `openclaw mcp list`, `show`, `status`, `doctor`, `probe`, `add`, `set`,
`configure`, `tools`, `login`, `logout`, `reload`, and `unset` path.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-managed MCP server definitions under `mcp.servers` in OpenClaw config. They do not read mcporter servers from `config/mcporter.json`.
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded OpenClaw and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.

View File

@@ -107,6 +107,10 @@ Notes:
in the shared managed skills directory when combined with `--global`.
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
default. There is no `--json` flag because JSON is already the default.
- When ClawHub returns server-resolved source provenance, verify JSON also
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
self-declared source URLs stay only in the raw provenance envelope and are not
promoted.
- `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it
verifies the installed version against the registry it came from. `--version`
and `--tag` override the version selector but keep that installed registry

View File

@@ -224,6 +224,29 @@ Optional members:
| `onSubagentEnded(params)` | Method | Clean up after a subagent ends. |
| `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload - not per-session. |
### Runtime settings
Lifecycle hooks that run inside OpenClaw receive an optional
`runtimeSettings` object. It is a versioned, read-only internal
producer/consumer API surface: OpenClaw produces it for the selected context
engine, and the context engine consumes it inside lifecycle hooks. It is not
rendered directly to users and does not create a dedicated reporting surface.
- `schemaVersion`: currently `1`
- `runtime`: OpenClaw host, runtime mode (`normal`, `fallback`, or
`degraded`), and optional harness/runtime ids
- `contextEngineSelection`: selected context engine id and selection source
- `executionHost`: host id and label for the surface invoking the hook
- `model`: requested model, resolved model, provider, and optional model family
- `limits`: prompt token budget and max output tokens when known
- `diagnostics`: closed fallback and degraded reason codes when known
Fields that can be unknown are represented as `null`; discriminator fields such
as runtime mode and selection source remain non-nullable. Older engines remain
compatible: if a strict legacy engine rejects `runtimeSettings` as an unknown
property, OpenClaw retries the lifecycle call without it instead of quarantining
the engine.
### Host requirements
Context engines can declare host capability requirements on `info.hostRequirements`.

View File

@@ -84,8 +84,7 @@ Set `memorySearch.provider` to switch away from OpenAI.
OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
80-token overlap) and stores them in a per-agent SQLite database.
- **Index location:** the owning agent database at
`~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite`
- **Index location:** `~/.openclaw/memory/<agentId>.sqlite`
- **Storage maintenance:** SQLite WAL sidecars are bounded with periodic and
shutdown checkpoints.
- **File watching:** changes to memory files trigger a debounced reindex (1.5s).

View File

@@ -258,7 +258,9 @@ Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
</Step>
</Steps>
Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, with `stats.cached` normalized into OpenClaw `cacheRead`.
Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream
messages and normalizes `stats.cached` into `cacheRead`; legacy
`--output-format json` overrides still read reply text from `response`.
### Z.AI (GLM)
@@ -294,6 +296,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
| --------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
| Cohere | `cohere` | `COHERE_API_KEY` | `cohere/command-a-03-2025` |
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |

View File

@@ -1386,7 +1386,11 @@
"clawhub/api",
"clawhub/http-api",
"clawhub/acceptable-usage",
"clawhub/content-rights"
"clawhub/moderation",
"clawhub/security",
"clawhub/security-audits",
"clawhub/content-rights",
"clawhub/plugin-validation-fixes"
]
}
]
@@ -1413,6 +1417,7 @@
"providers/azure-speech",
"providers/cerebras",
"providers/chutes",
"providers/cohere",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",
"providers/comfy",

View File

@@ -287,8 +287,10 @@ load local files from plain paths.
## Inputs / outputs
- `output: "json"` (default) tries to parse JSON and extract text + session id.
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and
usage from `stats` when `usage` is missing or empty.
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage
from `stats` when `usage` is missing or empty. The bundled Gemini CLI default
uses `stream-json`, but old `--output-format json` overrides still use the
JSON parser.
- `output: "jsonl"` parses JSONL streams and extracts the final agent message plus session
identifiers when present.
- `output: "text"` treats stdout as the final response.
@@ -318,8 +320,11 @@ The bundled Anthropic plugin registers a default for `claude-cli`:
The bundled Google plugin also registers a default for `google-gemini-cli`:
- `command: "gemini"`
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
- `args: ["--skip-trust", "--approval-mode", "auto_edit", "--output-format", "stream-json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--skip-trust", "--approval-mode", "auto_edit", "--resume", "{sessionId}", "--output-format", "stream-json", "--prompt", "{prompt}"]`
- `output: "jsonl"`
- `resumeOutput: "jsonl"`
- `jsonlDialect: "gemini-stream-json"`
- `imageArg: "@"`
- `imagePathScope: "workspace"`
- `modelArg: "--model"`
@@ -330,9 +335,13 @@ Prerequisite: the local Gemini CLI must be installed and available as
`gemini` on `PATH` (`brew install gemini-cli` or
`npm install -g @google/gemini-cli`).
Gemini CLI JSON notes:
Gemini CLI output notes:
- Reply text is read from the JSON `response` field.
- The default `stream-json` parser reads assistant `message` events, tool events,
final `result` usage, and fatal Gemini error events.
- If you override Gemini args to `--output-format json`, OpenClaw normalizes that
backend back to `output: "json"` and reads reply text from the JSON `response`
field.
- Usage falls back to `stats` when `usage` is absent or empty.
- `stats.cached` is normalized into OpenClaw `cacheRead`.
- If `stats.input` is missing, OpenClaw derives input tokens from
@@ -372,8 +381,10 @@ api.registerTextTransforms({
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
its own control markers and channel delivery.
For CLIs that emit Claude Code stream-json compatible JSONL, set
`jsonlDialect: "claude-stream-json"` on that backend's config.
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that
backend's config. Supported dialects are `claude-stream-json` for Claude
Code-compatible streams and `gemini-stream-json` for Gemini CLI `stream-json`
events.
## Native compaction ownership

View File

@@ -193,8 +193,8 @@ export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)"
```
These are gateway-host runtime env vars, not Fastlane settings. `apps/ios/fastlane/.env` only stores
App Store Connect / TestFlight auth such as `ASC_KEY_ID` and `ASC_ISSUER_ID`; it does not configure
direct APNs delivery for local iOS builds.
App Store Connect / TestFlight auth such as `APP_STORE_CONNECT_KEY_ID` and
`APP_STORE_CONNECT_ISSUER_ID`; it does not configure direct APNs delivery for local iOS builds.
Recommended gateway-host storage:

View File

@@ -103,8 +103,46 @@ Supported `appServer` fields:
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
`default_permissions` in the Codex thread config so the generated permission
profile can start Codex managed networking. By default, OpenClaw generates a
collision-resistant `openclaw-network-<fingerprint>` profile name from the
profile body; use `profileName` only when a stable local name is required.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.

View File

@@ -561,8 +561,52 @@ Supported `appServer` fields:
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
`default_permissions` in the Codex thread config so the generated permission
profile can start Codex managed networking. By default, OpenClaw generates a
collision-resistant `openclaw-network-<fingerprint>` profile name from the
profile body; use `profileName` only when a stable local name is required.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
unixSockets: {
"/tmp/proxy.sock": "allow",
"/tmp/blocked.sock": "none",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
`allow` or `none` values.
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
90 plugins
91 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -81,6 +81,8 @@ Each entry lists the package, distribution route, and description.
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw. Adds Cohere model provider support to OpenClaw.
- **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw.
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
generated plugin reference pages by distribution, package, and description.

View File

@@ -16,7 +16,7 @@ OpenClaw Codex app-server harness and model provider plugin with a Codex-managed
## Surface
providers: codex; contracts: mediaUnderstandingProviders, migrationProviders
providers: codex; contracts: mediaUnderstandingProviders, migrationProviders, webSearchProviders
## Related docs

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