mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 11:08:43 +08:00
Compare commits
500 Commits
jesse/cand
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dedb8e40 | ||
|
|
21e3cfa5e9 | ||
|
|
c8e70708e9 | ||
|
|
0bb415bf66 | ||
|
|
00c58b6613 | ||
|
|
50dbd46aa6 | ||
|
|
baeedaa316 | ||
|
|
0f71a665ed | ||
|
|
bcd1fdb1db | ||
|
|
1b5ac45e60 | ||
|
|
3d3d291902 | ||
|
|
57bad4bdf8 | ||
|
|
923828ccd7 | ||
|
|
7305d9baca | ||
|
|
212fef8703 | ||
|
|
7a008c53f4 | ||
|
|
93fa065fb3 | ||
|
|
bdc3017c1a | ||
|
|
6cb631afc9 | ||
|
|
fec6a0407f | ||
|
|
43cef3a80c | ||
|
|
e32929e12c | ||
|
|
f018945eff | ||
|
|
8b0eac7927 | ||
|
|
efa9a6110b | ||
|
|
32af1c0697 | ||
|
|
b3128ba93d | ||
|
|
eb67ac5cbe | ||
|
|
ef6b7e3659 | ||
|
|
a355825060 | ||
|
|
2cc25aa909 | ||
|
|
2758140607 | ||
|
|
55d1324c7d | ||
|
|
dc573a38dc | ||
|
|
9dbe25e0f6 | ||
|
|
e3ca10438a | ||
|
|
68cbd1ae63 | ||
|
|
0ea08076c3 | ||
|
|
04d8a96b6c | ||
|
|
5c2487dc9a | ||
|
|
c40db057da | ||
|
|
df521a6459 | ||
|
|
a0b16f37e8 | ||
|
|
cc000e0ffe | ||
|
|
9092578d8d | ||
|
|
a23de348b2 | ||
|
|
7650397a22 | ||
|
|
5a2641fc41 | ||
|
|
377f6181a9 | ||
|
|
b896e22e10 | ||
|
|
1ffda5d3ca | ||
|
|
ec2788cf80 | ||
|
|
379de52b59 | ||
|
|
0944045b75 | ||
|
|
c932bf377b | ||
|
|
ddb07fc597 | ||
|
|
caab343461 | ||
|
|
8e55348ff9 | ||
|
|
8e56cf591d | ||
|
|
40f1190c8b | ||
|
|
8ded756284 | ||
|
|
f00de6b06a | ||
|
|
158d3db85a | ||
|
|
6cd6e3f39e | ||
|
|
4bb4252042 | ||
|
|
95da644f6d | ||
|
|
6451550cd7 | ||
|
|
10a4c7c10b | ||
|
|
fa9b8c8c6b | ||
|
|
f5a9456219 | ||
|
|
7208567382 | ||
|
|
b90f7d2fd0 | ||
|
|
1e2363b687 | ||
|
|
c082a3b740 | ||
|
|
53541b2141 | ||
|
|
a3070e3ddf | ||
|
|
865e4db1cd | ||
|
|
31a10ee388 | ||
|
|
a34ddce9a2 | ||
|
|
f6a3ac7e58 | ||
|
|
95772c8541 | ||
|
|
85a635368e | ||
|
|
0888debcd3 | ||
|
|
794bd89fa0 | ||
|
|
93b7e3d717 | ||
|
|
da92615816 | ||
|
|
767e8280ac | ||
|
|
c1219d161d | ||
|
|
636aab6891 | ||
|
|
8f9493c213 | ||
|
|
826ea2bf85 | ||
|
|
0bbac63d00 | ||
|
|
1fef20c96b | ||
|
|
77d6ad6f65 | ||
|
|
206ac169e6 | ||
|
|
66079161d7 | ||
|
|
dd7f2ef002 | ||
|
|
018d279468 | ||
|
|
a09f6b1b27 | ||
|
|
d1b33a6040 | ||
|
|
8682d0701b | ||
|
|
4029fbd2b2 | ||
|
|
e7ee1c55b4 | ||
|
|
3ce3ed668d | ||
|
|
0314819f91 | ||
|
|
eab22a911a | ||
|
|
773ffd87a1 | ||
|
|
3add8af427 | ||
|
|
3fc850fe86 | ||
|
|
ba1be23821 | ||
|
|
233b48daaa | ||
|
|
501f63443f | ||
|
|
dae2bcf31b | ||
|
|
aaa5ce6280 | ||
|
|
6e8982f7a0 | ||
|
|
8ea848acb0 | ||
|
|
59e6452772 | ||
|
|
e573b751bf | ||
|
|
d012d29e6f | ||
|
|
44bf1c6d72 | ||
|
|
7a7165ad22 | ||
|
|
928b5932a3 | ||
|
|
77a682c5de | ||
|
|
efbefceb0e | ||
|
|
1c30bb8ce6 | ||
|
|
94833b2c90 | ||
|
|
1057e74438 | ||
|
|
55a6d8c57d | ||
|
|
b8967fc877 | ||
|
|
42759a1b79 | ||
|
|
5b18b7560e | ||
|
|
bcb016a528 | ||
|
|
b3f315461b | ||
|
|
5b460c4669 | ||
|
|
1f8c4d3958 | ||
|
|
04875efd28 | ||
|
|
7e0128ae65 | ||
|
|
1db8ab3734 | ||
|
|
cc954798f2 | ||
|
|
4fc805320f | ||
|
|
06431fd99b | ||
|
|
3d38c9a633 | ||
|
|
663fabbe30 | ||
|
|
c847db550f | ||
|
|
50c82b3020 | ||
|
|
dc46a67e80 | ||
|
|
7d8b000bf7 | ||
|
|
ac1042b09b | ||
|
|
fd80e0dd6b | ||
|
|
44e6caff54 | ||
|
|
d1eda0bd6f | ||
|
|
cae64ee360 | ||
|
|
e8db9c3bc0 | ||
|
|
e82d19fb06 | ||
|
|
870ec6dee2 | ||
|
|
fef8394079 | ||
|
|
1ca3d4f586 | ||
|
|
02acb0bd74 | ||
|
|
a1f18ef46e | ||
|
|
9ba6ed1d5c | ||
|
|
f1b8827d20 | ||
|
|
356385045f | ||
|
|
a189baa4b3 | ||
|
|
07dfdd4bd0 | ||
|
|
96f786d4b1 | ||
|
|
0100c27bc0 | ||
|
|
a8ca9619a5 | ||
|
|
3a8d422000 | ||
|
|
fc3e00cc11 | ||
|
|
a1b7f3570c | ||
|
|
b8ed2c3280 | ||
|
|
8bc728307d | ||
|
|
81924cfd5e | ||
|
|
6134c00657 | ||
|
|
4c2fef4a3b | ||
|
|
b470316fc0 | ||
|
|
7e12a3326d | ||
|
|
a42bda5b37 | ||
|
|
ecaebfc51b | ||
|
|
364461949d | ||
|
|
10b0dea77a | ||
|
|
aefd49909d | ||
|
|
c67dc59b02 | ||
|
|
e1744184b8 | ||
|
|
2e240e772b | ||
|
|
5836982506 | ||
|
|
40093f5a93 | ||
|
|
d0851435e8 | ||
|
|
3826cda4d8 | ||
|
|
771881d189 | ||
|
|
6db496b04b | ||
|
|
4892bbc10f | ||
|
|
7f6df80537 | ||
|
|
1a2e418500 | ||
|
|
a823cc3b1c | ||
|
|
fd855c831f | ||
|
|
ccf5976d06 | ||
|
|
b5999bc6a0 | ||
|
|
fc6d448138 | ||
|
|
2e745ba225 | ||
|
|
ef47dd610c | ||
|
|
fbc3fa3876 | ||
|
|
d28691da97 | ||
|
|
65e6d9c98c | ||
|
|
d498b1cce4 | ||
|
|
210877a73e | ||
|
|
d5b1d4529f | ||
|
|
36f6008842 | ||
|
|
cf88b4c024 | ||
|
|
a15427d605 | ||
|
|
23d74dad12 | ||
|
|
ae8da992ce | ||
|
|
4644e0c102 | ||
|
|
fff193402e | ||
|
|
c78f9376d9 | ||
|
|
d4a74b4993 | ||
|
|
aa04aef629 | ||
|
|
0716ebc1e5 | ||
|
|
cda040b4e5 | ||
|
|
00479b12d1 | ||
|
|
2a6eeceb40 | ||
|
|
30f5e6f639 | ||
|
|
dae9345407 | ||
|
|
a12942518e | ||
|
|
b2c5e790b4 | ||
|
|
37e3e895b0 | ||
|
|
2f34d06b42 | ||
|
|
db5e415888 | ||
|
|
b9e8e6d66e | ||
|
|
a57b4c513f | ||
|
|
333a93ce44 | ||
|
|
46a5a5ee5f | ||
|
|
af091174db | ||
|
|
1affe4fcdf | ||
|
|
439a9e97fd | ||
|
|
1c86769cb8 | ||
|
|
b1caba5906 | ||
|
|
d2c0d3ac9b | ||
|
|
e99ab385cf | ||
|
|
19130e0dc2 | ||
|
|
8d08b90489 | ||
|
|
a3e9dfee0e | ||
|
|
cf4000b47b | ||
|
|
85ebbec117 | ||
|
|
f85ef0d114 | ||
|
|
d1299658ac | ||
|
|
a02813164d | ||
|
|
462c076a24 | ||
|
|
d68de3f77d | ||
|
|
2e20dd8dbf | ||
|
|
66880a5d73 | ||
|
|
231b5a14d5 | ||
|
|
c5db07eddc | ||
|
|
808f677ab4 | ||
|
|
7259cb5c77 | ||
|
|
889bc52ba5 | ||
|
|
c8cf57a1a5 | ||
|
|
9a0bae06b7 | ||
|
|
7f49f875de | ||
|
|
bd10e1998b | ||
|
|
650c5cac33 | ||
|
|
965fa05df3 | ||
|
|
9f32bea397 | ||
|
|
7fb0d45b48 | ||
|
|
8ae1adfdcc | ||
|
|
4001be54e4 | ||
|
|
e8b35a8280 | ||
|
|
077d3f1366 | ||
|
|
8afbc98018 | ||
|
|
f58f8c86b9 | ||
|
|
24e729fc4e | ||
|
|
aef670cf0c | ||
|
|
399f5bc993 | ||
|
|
34678d8dfa | ||
|
|
c60b424124 | ||
|
|
340c2456bb | ||
|
|
971542b7f6 | ||
|
|
b3dc274034 | ||
|
|
1acca038b1 | ||
|
|
fd4f5b3f59 | ||
|
|
ac3a98e55d | ||
|
|
57e8c50d19 | ||
|
|
274b7b1d9f | ||
|
|
efca4b7e64 | ||
|
|
65b460f234 | ||
|
|
4c3c0ff5f9 | ||
|
|
9a27af9507 | ||
|
|
1d9b9ef48f | ||
|
|
8f62ec6177 | ||
|
|
b72634f56d | ||
|
|
99e7dad0e4 | ||
|
|
d626e99c31 | ||
|
|
c2754150c9 | ||
|
|
5b21384ab6 | ||
|
|
edd76238fe | ||
|
|
d6b3950734 | ||
|
|
61145dc252 | ||
|
|
382db15e33 | ||
|
|
1a8747620e | ||
|
|
e55cebf4c2 | ||
|
|
2d4a9eb405 | ||
|
|
47759c3506 | ||
|
|
3429e33feb | ||
|
|
894f521aa5 | ||
|
|
97c5e6c235 | ||
|
|
9974641d1e | ||
|
|
924f4c1964 | ||
|
|
2f57352eaa | ||
|
|
c11fcbcb6a | ||
|
|
5b6810211c | ||
|
|
e4313bac97 | ||
|
|
6ebb303ef0 | ||
|
|
ae68006a8f | ||
|
|
735f59af73 | ||
|
|
47112fc423 | ||
|
|
8549a203d4 | ||
|
|
d912909230 | ||
|
|
e6ffcf7362 | ||
|
|
8047350445 | ||
|
|
15e4fbf593 | ||
|
|
4e4ea1c16b | ||
|
|
b2da129e51 | ||
|
|
5b21a0337b | ||
|
|
dbf24fe35a | ||
|
|
d03932af18 | ||
|
|
13a079b3f8 | ||
|
|
e58310b000 | ||
|
|
a1814586c6 | ||
|
|
ca2410ab07 | ||
|
|
d20fdf3b38 | ||
|
|
689ebc815b | ||
|
|
22069bcc56 | ||
|
|
b01a54de6f | ||
|
|
45e36a241a | ||
|
|
5cb6f8aa9f | ||
|
|
b9ad8649d0 | ||
|
|
4e8a527542 | ||
|
|
0eb92fa79c | ||
|
|
f1e303404c | ||
|
|
80d2b40fac | ||
|
|
a3bc0097c8 | ||
|
|
93318050e1 | ||
|
|
18fbcef496 | ||
|
|
e8b142feb1 | ||
|
|
547cc0f109 | ||
|
|
bb71f46251 | ||
|
|
a6aa84f2d0 | ||
|
|
3b94949437 | ||
|
|
45056a463a | ||
|
|
c773d8cd8e | ||
|
|
eb1b640854 | ||
|
|
ddacb7ba39 | ||
|
|
762d8d8e64 | ||
|
|
205ab8d4bd | ||
|
|
7994880864 | ||
|
|
afe75b3387 | ||
|
|
84cbaf1832 | ||
|
|
5892dc8522 | ||
|
|
a55accb4b6 | ||
|
|
cdd71103c9 | ||
|
|
7328caba82 | ||
|
|
3ec16bbad3 | ||
|
|
cc831f8684 | ||
|
|
89cc175b2e | ||
|
|
3c02c239b4 | ||
|
|
7359206b76 | ||
|
|
37d6fd2e81 | ||
|
|
8ecf55b36a | ||
|
|
2e8a2d617d | ||
|
|
27e24ca683 | ||
|
|
68e234f9e2 | ||
|
|
5854e0c8f6 | ||
|
|
eaeedbf1f9 | ||
|
|
dc493bc9a2 | ||
|
|
78c66742ab | ||
|
|
4c23d1d597 | ||
|
|
8eb1fa09c6 | ||
|
|
2d2c1e63f0 | ||
|
|
6cdbccaa9e | ||
|
|
9f522ee7df | ||
|
|
7404b2b5b4 | ||
|
|
73aabcceda | ||
|
|
b1fc8673df | ||
|
|
4cf4e54179 | ||
|
|
84519f7e3c | ||
|
|
6314c377bb | ||
|
|
d3e7e03669 | ||
|
|
64f9f3c278 | ||
|
|
cd3eb438f0 | ||
|
|
26281a8a11 | ||
|
|
4208c89ec4 | ||
|
|
c9c19a1106 | ||
|
|
f78d7b52d8 | ||
|
|
ff6940036b | ||
|
|
b477bfe84b | ||
|
|
d4237cb14d | ||
|
|
20bc546d94 | ||
|
|
069cb8d636 | ||
|
|
8cc5d2d85c | ||
|
|
690f27749c | ||
|
|
7af8153388 | ||
|
|
a66a065ffb | ||
|
|
64d0fc8336 | ||
|
|
f3df863aff | ||
|
|
26b9736922 | ||
|
|
44f45d8729 | ||
|
|
1c655008cd | ||
|
|
618d78144e | ||
|
|
56f2102c28 | ||
|
|
99db98a7ce | ||
|
|
0dbfa1f6be | ||
|
|
f06f2f17c2 | ||
|
|
7bd533a80e | ||
|
|
561b293c7a | ||
|
|
21aa8faf8a | ||
|
|
b0bd9c8ed8 | ||
|
|
c5d599c8c4 | ||
|
|
3a1a5c0dac | ||
|
|
0849cac106 | ||
|
|
32ce06daf8 | ||
|
|
751d3db1cc | ||
|
|
4640baa299 | ||
|
|
c9f0bfd476 | ||
|
|
ab559a7257 | ||
|
|
7c08804541 | ||
|
|
991471b8ec | ||
|
|
7190fc4de8 | ||
|
|
9d9389bc6b | ||
|
|
6c88811b4b | ||
|
|
4a6666796f | ||
|
|
68222ba5e3 | ||
|
|
1bd783045b | ||
|
|
6cf06e8e7e | ||
|
|
ded3a93058 | ||
|
|
0063f3076c | ||
|
|
8c7e5c6918 | ||
|
|
e338037034 | ||
|
|
05796759ad | ||
|
|
d8b3e523ff | ||
|
|
4809ac70fa | ||
|
|
777edadb36 | ||
|
|
8d9ce35b92 | ||
|
|
69bf333dde | ||
|
|
e3a6da0f51 | ||
|
|
8ec1c0676b | ||
|
|
e4b6b9ea66 | ||
|
|
aba3751ad7 | ||
|
|
9921825e17 | ||
|
|
652e616a29 | ||
|
|
f385491c23 | ||
|
|
7387083a95 | ||
|
|
462092936a | ||
|
|
da4671ebcc | ||
|
|
9386d6214f | ||
|
|
8673c65c6b | ||
|
|
f3eb8e9714 | ||
|
|
f80f472190 | ||
|
|
3643de4ba7 | ||
|
|
41a9277844 | ||
|
|
79901fb4ba | ||
|
|
e728957989 | ||
|
|
d9124c9700 | ||
|
|
81c553e2fb | ||
|
|
0fc5a57a34 | ||
|
|
1bd04ac983 | ||
|
|
294779e5d6 | ||
|
|
888835cfe6 | ||
|
|
a716950a3c | ||
|
|
667bc2c4ca | ||
|
|
01b004c594 | ||
|
|
9cf1ef1d90 | ||
|
|
1c5099803f | ||
|
|
0d4968d466 | ||
|
|
0efe5857bc | ||
|
|
fed2c36611 | ||
|
|
bcc1105b30 | ||
|
|
b750d314b7 | ||
|
|
d4819948f3 | ||
|
|
4a3d06ee37 | ||
|
|
ff04e24ead | ||
|
|
a956ab8481 | ||
|
|
3b78d41a9e | ||
|
|
3c9c4aa428 | ||
|
|
6223a538bc | ||
|
|
8be3beec74 | ||
|
|
6a2ec62865 | ||
|
|
301213a05f | ||
|
|
9827490f5f | ||
|
|
575cae59d4 | ||
|
|
4e4dc10db0 | ||
|
|
34a1102506 | ||
|
|
1156ab637c | ||
|
|
0fc2faa0f4 | ||
|
|
fdf3667e09 | ||
|
|
de9260f813 | ||
|
|
4d45884419 | ||
|
|
1bea7d8ef3 | ||
|
|
008d785a80 | ||
|
|
eebcb100b8 | ||
|
|
b5295a6a34 |
@@ -54,6 +54,13 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
|
||||
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
|
||||
Testbox policy applies.
|
||||
- Cold Testbox acquisition and hydration often take tens of seconds. When broad
|
||||
remote proof is likely, immediately start
|
||||
`node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json`
|
||||
in a background command session while inspecting, editing, and running
|
||||
focused local tests. Poll later, reuse the returned `tbx_...` with
|
||||
`--provider blacksmith-testbox --id <tbx_id>`, and stop it before handoff.
|
||||
Do not warm speculatively when remote proof is unlikely.
|
||||
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
|
||||
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
|
||||
`blacksmith testbox list`, use `blacksmith testbox list --all` before
|
||||
|
||||
51
.agents/skills/discord-user-post/SKILL.md
Normal file
51
.agents/skills/discord-user-post/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: discord-user-post
|
||||
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
|
||||
---
|
||||
|
||||
# Discord User Post
|
||||
|
||||
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
|
||||
existing logged-in session. This workflow represents the user directly.
|
||||
|
||||
## Prepare
|
||||
|
||||
1. Draft the complete final message outside Discord.
|
||||
2. Confirm the intended server and channel with the user when either is
|
||||
ambiguous.
|
||||
3. Open Discord and navigate to the exact destination without entering the
|
||||
message.
|
||||
4. Verify the visible server name, channel header, and logged-in account.
|
||||
|
||||
Do not infer the target from unrelated Discord content. Stop if Discord is not
|
||||
logged in, the account is wrong, or the exact destination cannot be verified.
|
||||
|
||||
## Confirm and Post
|
||||
|
||||
Posting is representational communication. Follow the `$computer-use`
|
||||
confirmation policy even when the user previously asked for an announcement:
|
||||
|
||||
1. Show the user the exact final body and verified destination.
|
||||
2. Request action-time confirmation before typing into Discord.
|
||||
3. After confirmation, enter the approved body unchanged.
|
||||
4. Visually inspect the composed message and destination again.
|
||||
5. Send once.
|
||||
|
||||
If the body or destination changes after confirmation, request confirmation
|
||||
again before sending.
|
||||
|
||||
## Verify
|
||||
|
||||
- Confirm the message appears once, from the user's account, in the intended
|
||||
channel.
|
||||
- Report the server, channel, and visible send result.
|
||||
- Do not edit, delete, react, or send a follow-up without the corresponding
|
||||
user instruction and confirmation.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
|
||||
or token for this workflow.
|
||||
- Never expose private Discord content or account details in public output.
|
||||
- Never send a draft, partial message, duplicate, or unreviewed attachment.
|
||||
- For Discord archive/history/search, use `$discrawl` instead.
|
||||
4
.agents/skills/discord-user-post/agents/openai.yaml
Normal file
4
.agents/skills/discord-user-post/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Discord User Post"
|
||||
short_description: "Post approved messages through the logged-in Discord app"
|
||||
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."
|
||||
@@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
|
||||
- `docs/help/testing.md`
|
||||
- `docs/channels/qa-channel.md`
|
||||
- `qa/README.md`
|
||||
- `qa/scenarios/index.md`
|
||||
- `qa/scenarios/index.yaml`
|
||||
- `extensions/qa-lab/src/suite.ts`
|
||||
- `extensions/qa-lab/src/character-eval.ts`
|
||||
|
||||
@@ -198,7 +198,9 @@ pnpm openclaw qa character-eval \
|
||||
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
|
||||
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
|
||||
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
|
||||
- Scenario source should stay markdown-driven under `qa/scenarios/`.
|
||||
- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and
|
||||
per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional
|
||||
`flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files.
|
||||
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
|
||||
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
|
||||
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
|
||||
@@ -234,7 +236,8 @@ pnpm openclaw qa manual \
|
||||
|
||||
## Repo facts
|
||||
|
||||
- Seed scenarios live in `qa/`.
|
||||
- Seed scenarios live in `qa/scenarios/index.yaml` and
|
||||
`qa/scenarios/<theme>/*.yaml`.
|
||||
- Main live runner: `extensions/qa-lab/src/suite.ts`
|
||||
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
|
||||
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
|
||||
@@ -262,8 +265,9 @@ pnpm openclaw qa manual \
|
||||
|
||||
## When adding scenarios
|
||||
|
||||
- Add or update scenario markdown under `qa/scenarios/`
|
||||
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
|
||||
- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario
|
||||
files or fenced YAML blocks.
|
||||
- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned
|
||||
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
|
||||
- Prefer end-to-end assertions over mock-only checks
|
||||
- Save outputs under `.artifacts/qa-e2e/`
|
||||
|
||||
@@ -6,7 +6,8 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
|
||||
# OpenClaw Release Announcement
|
||||
|
||||
Use with `release-openclaw-maintainer` after a beta or stable release is live.
|
||||
Use with `openclaw-discord` when actually posting to Discord.
|
||||
Use with `$discord-user-post` when actually posting to Discord as the logged-in
|
||||
user.
|
||||
|
||||
## Evidence First
|
||||
|
||||
@@ -80,6 +81,7 @@ Fresh installs still point to `https://openclaw.ai`.
|
||||
|
||||
## Posting
|
||||
|
||||
When asked to post, use the configured Discord workflow from
|
||||
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
|
||||
For public channels, inspect the final body before sending.
|
||||
When asked to post, use `$discord-user-post` to operate the logged-in Discord
|
||||
desktop app as the user. Resolve and visibly verify the exact server/channel,
|
||||
inspect the final body, and request action-time confirmation before entering or
|
||||
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.
|
||||
|
||||
@@ -150,9 +150,21 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Stable Windows Hub release closeout requires the signed
|
||||
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
|
||||
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
|
||||
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
|
||||
workflow after the matching `openclaw/openclaw-windows-node` release exists;
|
||||
it verifies Authenticode signatures on Windows before uploading assets.
|
||||
`openclaw/openclaw` GitHub Release. Pass the exact signed
|
||||
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
|
||||
`OpenClaw Release Publish`, together with the candidate-approved
|
||||
`windows_node_installer_digests` map; it prevalidates the published source
|
||||
release and required installers against that map before any publish child,
|
||||
dispatches the public `Windows Node Release` workflow while the OpenClaw
|
||||
release is still a draft, carries those pinned source asset digests
|
||||
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
|
||||
Windows, re-downloads and checksum-verifies the promoted asset contract, and
|
||||
blocks publication until the canonical asset contract is present. Use direct
|
||||
`Windows Node Release` dispatch only for recovery, always with an exact tag,
|
||||
never `latest`, and the explicit `expected_installer_digests` JSON map from
|
||||
the approved source release. Recovery rejects unexpected
|
||||
`OpenClawCompanion-*` target asset names, then replaces the expected contract
|
||||
assets with the pinned source bytes.
|
||||
- Website Windows Hub download links should target exact canonical
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
|
||||
stable release, or `releases/latest/download/...` only after verifying the
|
||||
@@ -309,6 +321,7 @@ Upgrade with the beta channel.
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
pnpm release:fast-pretag-check
|
||||
pnpm check:architecture
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
@@ -317,6 +330,21 @@ pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
|
||||
publishable plugin must have a non-empty package-root `README.md`, build its
|
||||
package-local runtime, and pass the npm and ClawHub release metadata checks
|
||||
before a tag or publish workflow can start. Do not defer README, entrypoint,
|
||||
or packed-artifact failures to postpublish verification.
|
||||
- Before tagging, require green CI for the exact release-candidate SHA, not an
|
||||
earlier branch SHA. Heal every related red CI, release-check, packaging, or
|
||||
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
|
||||
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
|
||||
npm preflight passed.
|
||||
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
|
||||
release `install-smoke` group or equivalent root Dockerfile build for the
|
||||
exact candidate SHA and require it to pass. The tag-triggered Docker Release
|
||||
workflow is post-tag publishing, not the first valid proof that the root
|
||||
Dockerfile can build.
|
||||
- Before tagging, diff publishable plugin package manifests against the last
|
||||
reachable stable/beta release tag. For every newly publishable package
|
||||
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
|
||||
@@ -632,9 +660,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
off, live OpenAI off, and regression failure off. Let it run in parallel
|
||||
with preflight and validation work.
|
||||
10. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
|
||||
to be green before tagging. Keep the remaining expensive Docker, Parallels,
|
||||
and published-package install/update lanes for after the beta is live unless
|
||||
the operator asks to run them before beta publication.
|
||||
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
release blocker specifically requires it. For stable releases, include the
|
||||
mac app, signing, notarization, and appcast path.
|
||||
@@ -675,19 +704,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
where npm did not publish the beta version, delete/recreate the same beta
|
||||
tag and any accidental draft/incomplete prerelease at the fixed commit
|
||||
instead of skipping a prerelease number.
|
||||
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
the same as the preflight run, and pass the successful npm
|
||||
`preflight_run_id`.
|
||||
`preflight_run_id` plus the successful `full_release_validation_run_id`.
|
||||
For stable publish, also pass the exact non-prerelease
|
||||
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
|
||||
candidate-approved installer digest map as `windows_node_installer_digests`.
|
||||
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
24. Wait for the real publish workflow to run postpublish verification,
|
||||
create or update the GitHub release as a draft, upload dependency evidence,
|
||||
promote and verify the required Windows Hub assets for stable releases,
|
||||
append release verification proof, and only then undraft/publish it. If a
|
||||
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
|
||||
the release draft with OpenClaw npm evidence and exits red; do not undraft
|
||||
until the plugin publish gap is repaired. The standalone verifier command
|
||||
remains the recovery probe:
|
||||
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
|
||||
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
|
||||
and exits red; do not undraft until the gap is repaired. The standalone
|
||||
verifier command remains the recovery probe:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -1288,6 +1288,7 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_LOCAL_CHECK: "0"
|
||||
TASK: ${{ matrix.task }}
|
||||
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1297,6 +1298,10 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
|
||||
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
|
||||
fi
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1358,6 +1363,10 @@ jobs:
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1504,6 +1513,18 @@ jobs:
|
||||
boundaries)
|
||||
node scripts/run-additional-boundary-checks.mjs
|
||||
;;
|
||||
session-accessor-boundary)
|
||||
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
|
||||
echo "[skip] session accessor boundary check is not present in this checkout"
|
||||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
|
||||
echo "[skip] session accessor boundary script is not present in package.json"
|
||||
else
|
||||
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
;;
|
||||
|
||||
@@ -783,7 +783,7 @@ jobs:
|
||||
fi
|
||||
|
||||
args=(
|
||||
-f ref="$TARGET_SHA"
|
||||
-f ref="$TARGET_REF"
|
||||
-f expected_sha="$TARGET_SHA"
|
||||
-f provider="$PROVIDER"
|
||||
-f mode="$MODE"
|
||||
|
||||
447
.github/workflows/ios-periphery-comment.yml
vendored
Normal file
447
.github/workflows/ios-periphery-comment.yml
vendored
Normal file
@@ -0,0 +1,447 @@
|
||||
name: iOS Periphery Dead Code Comment
|
||||
|
||||
on:
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
|
||||
workflows: ["iOS Periphery Dead Code"]
|
||||
types: [completed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment on PR
|
||||
runs-on: ubuntu-24.04
|
||||
if: >
|
||||
github.repository == 'openclaw/openclaw' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.name == 'iOS Periphery Dead Code'
|
||||
steps:
|
||||
- name: Upsert Periphery PR comment
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
|
||||
const run = context.payload.workflow_run;
|
||||
const pr = run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
core.info("No pull request attached to workflow_run.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const repository = `${owner}/${repo}`;
|
||||
if (run.repository?.full_name !== repository) {
|
||||
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (run.event !== "pull_request") {
|
||||
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
|
||||
return;
|
||||
}
|
||||
if (run.name !== "iOS Periphery Dead Code") {
|
||||
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const livePull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (livePull.data.state !== "open") {
|
||||
core.info(`Skipping closed PR #${pr.number}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.base?.repo?.full_name !== repository) {
|
||||
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.head?.sha !== run.head_sha) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
|
||||
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
|
||||
const scanSkipped =
|
||||
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
|
||||
if (scanSkipped) {
|
||||
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
|
||||
}
|
||||
|
||||
const artifacts = scanSkipped
|
||||
? []
|
||||
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const readReport = async () => {
|
||||
if (scanSkipped) {
|
||||
return;
|
||||
}
|
||||
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
|
||||
const artifact = artifacts.find((item) => item.name === artifactName);
|
||||
if (!artifact) {
|
||||
core.warning(`No ${artifactName} artifact found.`);
|
||||
return;
|
||||
}
|
||||
if (artifact.expired) {
|
||||
core.warning(`${artifactName} artifact expired.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxArchiveBytes = 1024 * 1024;
|
||||
const archiveSize = Number(artifact.size_in_bytes);
|
||||
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
|
||||
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const archive = await github.rest.actions.downloadArtifact({
|
||||
owner,
|
||||
repo,
|
||||
artifact_id: artifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
|
||||
const archivePath = path.join(dir, "artifact.zip");
|
||||
const archiveBuffer = Buffer.from(archive.data);
|
||||
fs.writeFileSync(archivePath, archiveBuffer);
|
||||
|
||||
const allowedArtifactFiles = new Set([
|
||||
"periphery.json",
|
||||
"periphery.status",
|
||||
"periphery.stderr.log",
|
||||
"periphery.stdout.json",
|
||||
"should-fail.txt",
|
||||
]);
|
||||
const maxEntries = allowedArtifactFiles.size;
|
||||
const maxEntryBytes = 2 * 1024 * 1024;
|
||||
const maxTotalBytes = 4 * 1024 * 1024;
|
||||
|
||||
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
|
||||
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
|
||||
const findEndOfCentralDirectoryOffset = () => {
|
||||
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
|
||||
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
|
||||
if (readUInt32(offset) === 0x06054b50) {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
|
||||
if (endOfCentralDirectoryOffset < 0) {
|
||||
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
|
||||
return;
|
||||
}
|
||||
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
|
||||
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
|
||||
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
|
||||
if (entryCount < 1 || entryCount > maxEntries) {
|
||||
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
|
||||
readUInt32(centralDirectoryOffset) !== 0x02014b50
|
||||
) {
|
||||
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = new Map();
|
||||
let totalUncompressedSize = 0;
|
||||
let offset = centralDirectoryOffset;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
|
||||
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compressionMethod = readUInt16(offset + 10);
|
||||
const generalPurposeBitFlag = readUInt16(offset + 8);
|
||||
const compressedSize = readUInt32(offset + 20);
|
||||
const uncompressedSize = readUInt32(offset + 24);
|
||||
const fileNameLength = readUInt16(offset + 28);
|
||||
const extraLength = readUInt16(offset + 30);
|
||||
const commentLength = readUInt16(offset + 32);
|
||||
const externalAttributes = readUInt32(offset + 38);
|
||||
const nameStart = offset + 46;
|
||||
const nameEnd = nameStart + fileNameLength;
|
||||
const nextOffset = nameEnd + extraLength + commentLength;
|
||||
if (nextOffset > archiveBuffer.length) {
|
||||
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
|
||||
const mode = externalAttributes >>> 16;
|
||||
const fileType = mode & 0o170000;
|
||||
const isRegularFile = fileType === 0 || fileType === 0o100000;
|
||||
const invalidName =
|
||||
!allowedArtifactFiles.has(name) ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\") ||
|
||||
name.includes("..") ||
|
||||
path.isAbsolute(name);
|
||||
if (invalidName) {
|
||||
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (!isRegularFile || name.endsWith("/")) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
|
||||
return;
|
||||
}
|
||||
if (entries.has(name)) {
|
||||
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (![0, 8].includes(compressionMethod)) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
|
||||
return;
|
||||
}
|
||||
if ((generalPurposeBitFlag & 0x1) !== 0) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
|
||||
return;
|
||||
}
|
||||
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
totalUncompressedSize += uncompressedSize;
|
||||
if (totalUncompressedSize > maxTotalBytes) {
|
||||
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.set(name, { uncompressedSize });
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const files = new Map();
|
||||
for (const [name, entry] of entries) {
|
||||
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
|
||||
timeout: 5000,
|
||||
});
|
||||
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
|
||||
return;
|
||||
}
|
||||
files.set(name, contents);
|
||||
}
|
||||
|
||||
const read = (name) => {
|
||||
return files.get(name) ?? "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
const validFindings =
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every(
|
||||
(finding) =>
|
||||
finding !== null &&
|
||||
typeof finding === "object" &&
|
||||
!Array.isArray(finding),
|
||||
);
|
||||
if (validFindings) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { findings, status };
|
||||
};
|
||||
const report = await readReport();
|
||||
const status = report?.status ?? 1;
|
||||
const findings = report?.findings ?? null;
|
||||
|
||||
const sanitizeCell = (value) => {
|
||||
const normalized = String(value ?? "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
const maxEncodedLength = 180;
|
||||
let escaped = "";
|
||||
for (const character of normalized) {
|
||||
const encoded =
|
||||
character === "`"
|
||||
? "'"
|
||||
: character === "|"
|
||||
? "\\|"
|
||||
: character;
|
||||
if (escaped.length + encoded.length > maxEncodedLength) {
|
||||
break;
|
||||
}
|
||||
escaped += encoded;
|
||||
}
|
||||
return `\`${escaped || "-"}\``;
|
||||
};
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
return {
|
||||
file: file ? `apps/ios/${file}` : "",
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
let mode = "failure";
|
||||
let body = `${marker}\n`;
|
||||
if (scanSkipped) {
|
||||
mode = "skipped";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
|
||||
].join("\n");
|
||||
} else if (findings === null) {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
mode = "success";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
const shown = rows.slice(0, 50);
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
|
||||
"",
|
||||
"| File | Line | Kind | Name |",
|
||||
"| --- | ---: | --- | --- |",
|
||||
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
|
||||
rows.length > shown.length ? "" : null,
|
||||
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
} else {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
body += "\n";
|
||||
const maxCommentChars = 60_000;
|
||||
if (body.length > maxCommentChars) {
|
||||
body = [
|
||||
marker,
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
if (!existing && ["skipped", "success"].includes(mode)) {
|
||||
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (
|
||||
currentPull.data.state !== "open" ||
|
||||
currentPull.data.base?.repo?.full_name !== repository ||
|
||||
currentPull.data.head?.sha !== run.head_sha
|
||||
) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: run.workflow_id,
|
||||
event: "pull_request",
|
||||
head_sha: run.head_sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const supersedingRun = workflowRuns.find(
|
||||
(candidate) =>
|
||||
(candidate.id === run.id ||
|
||||
candidate.pull_requests?.some(
|
||||
(candidatePull) => candidatePull.number === pr.number,
|
||||
)) &&
|
||||
(candidate.run_number > run.run_number ||
|
||||
(candidate.run_number === run.run_number &&
|
||||
candidate.run_attempt > run.run_attempt)),
|
||||
);
|
||||
if (supersedingRun) {
|
||||
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
body,
|
||||
});
|
||||
229
.github/workflows/ios-periphery.yml
vendored
Normal file
229
.github/workflows/ios-periphery.yml
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
name: iOS Periphery Dead Code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
name: Detect iOS scan scope
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should-scan: ${{ steps.scope.outputs.should-scan }}
|
||||
steps:
|
||||
- name: Detect changed paths
|
||||
id: scope
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
core.setOutput("should-scan", "true");
|
||||
return;
|
||||
}
|
||||
if (context.payload.pull_request?.draft) {
|
||||
core.setOutput("should-scan", "false");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const isScanPath = (filename) =>
|
||||
typeof filename === "string" && (
|
||||
filename.startsWith("apps/ios/") ||
|
||||
filename === ".github/workflows/ios-periphery.yml" ||
|
||||
filename === ".github/workflows/ios-periphery-comment.yml" ||
|
||||
filename === "config/swiftformat" ||
|
||||
filename === "config/swiftlint.yml"
|
||||
);
|
||||
const shouldScan = files.some(
|
||||
({ filename, previous_filename: previousFilename }) =>
|
||||
isScanPath(filename) || isScanPath(previousFilename)
|
||||
);
|
||||
core.setOutput("should-scan", String(shouldScan));
|
||||
|
||||
scan:
|
||||
name: Scan iOS dead code
|
||||
needs: scope
|
||||
if: ${{ needs.scope.outputs.should-scan == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Verify Xcode
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftformat swiftlint periphery
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/ios-configure-signing.sh
|
||||
./scripts/ios-write-version-xcconfig.sh
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: Run Periphery
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output_dir="$RUNNER_TEMP/ios-periphery"
|
||||
mkdir -p "$output_dir"
|
||||
cd apps/ios
|
||||
set +e
|
||||
periphery scan \
|
||||
--config .periphery.yml \
|
||||
--strict \
|
||||
--format json \
|
||||
--write-results "$output_dir/periphery.json" \
|
||||
>"$output_dir/periphery.stdout.json" \
|
||||
2>"$output_dir/periphery.stderr.log"
|
||||
periphery_status="$?"
|
||||
set -e
|
||||
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
|
||||
if [ ! -s "$output_dir/periphery.json" ]; then
|
||||
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
|
||||
fi
|
||||
|
||||
- name: Build Periphery report
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery");
|
||||
const read = (name) => {
|
||||
const file = path.join(outputDir, name);
|
||||
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
if (Array.isArray(parsed)) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const escapeCommandData = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("%", "%25")
|
||||
.replaceAll("\r", "%0D")
|
||||
.replaceAll("\n", "%0A");
|
||||
const escapeCommandProperty = (value) =>
|
||||
escapeCommandData(value)
|
||||
.replaceAll(":", "%3A")
|
||||
.replaceAll(",", "%2C");
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
const repoFile = file ? `apps/ios/${file}` : "";
|
||||
return {
|
||||
file: repoFile,
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.file) continue;
|
||||
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
|
||||
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
|
||||
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
|
||||
}
|
||||
|
||||
let shouldFail = "1";
|
||||
let summary = "";
|
||||
|
||||
if (findings === null) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
shouldFail = "0";
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
|
||||
].join("\n");
|
||||
} else {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload Periphery report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"
|
||||
@@ -437,8 +437,17 @@ jobs:
|
||||
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
|
||||
fi
|
||||
|
||||
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
|
||||
read_discord_status_reaction_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_status_reaction_status baseline)"
|
||||
candidate_status="$(read_discord_status_reaction_status candidate)"
|
||||
|
||||
jq -n \
|
||||
--arg baseline_status "$baseline_status" \
|
||||
|
||||
@@ -451,8 +451,17 @@ jobs:
|
||||
|
||||
capture_candidate_discord_web
|
||||
|
||||
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
|
||||
read_discord_thread_attachment_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_thread_attachment_status baseline)"
|
||||
candidate_status="$(read_discord_thread_attachment_status candidate)"
|
||||
comparison_status="fail"
|
||||
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
|
||||
comparison_status="pass"
|
||||
|
||||
5
.github/workflows/mantis-telegram-live.yml
vendored
5
.github/workflows/mantis-telegram-live.yml
vendored
@@ -379,7 +379,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
||||
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
@@ -445,8 +444,8 @@ jobs:
|
||||
telegram_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce a summary." >&2
|
||||
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce an evidence summary." >&2
|
||||
exit "$telegram_exit"
|
||||
fi
|
||||
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -220,7 +220,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
|
||||
run: |
|
||||
|
||||
@@ -420,6 +420,7 @@ jobs:
|
||||
add_suite live-cache
|
||||
|
||||
add_profile_suite native-live-src-agents "stable full"
|
||||
add_profile_suite native-live-src-agents-zai-coding "stable full"
|
||||
add_profile_suite native-live-src-gateway-core "beta minimum stable full"
|
||||
add_profile_suite native-live-src-gateway-profiles-anthropic "stable full"
|
||||
add_profile_suite native-live-src-gateway-profiles-anthropic-smoke "stable"
|
||||
@@ -1748,6 +1749,7 @@ jobs:
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
@@ -1836,7 +1838,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
|
||||
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
|
||||
|
||||
normalize_provider() {
|
||||
local value="${1,,}"
|
||||
@@ -1922,6 +1924,7 @@ jobs:
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
@@ -1954,6 +1957,12 @@ jobs:
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-agents-zai-coding
|
||||
label: Native live Z.AI Coding Plan
|
||||
command: ZAI_CODING_LIVE_TEST=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-agents-zai-coding
|
||||
timeout_minutes: 15
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-core
|
||||
label: Native live gateway core
|
||||
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
|
||||
|
||||
29
.github/workflows/openclaw-performance.yml
vendored
29
.github/workflows/openclaw-performance.yml
vendored
@@ -56,6 +56,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
|
||||
@@ -187,11 +188,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
KOVA_SRC="${RUNNER_TEMP}/kova-src"
|
||||
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
|
||||
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
|
||||
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
|
||||
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" checkout "$KOVA_REF"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
|
||||
|
||||
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
|
||||
-o "$ocm_archive" \
|
||||
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
|
||||
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
|
||||
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
|
||||
|
||||
git init -b main "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
|
||||
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
|
||||
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
|
||||
cat > "$HOME/.local/bin/kova" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
export KOVA_HOME="${KOVA_HOME}"
|
||||
@@ -527,6 +537,13 @@ jobs:
|
||||
cleanup_gateway
|
||||
trap - EXIT
|
||||
|
||||
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
|
||||
pnpm test:sqlite:perf:smoke
|
||||
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
|
||||
else
|
||||
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
|
||||
--source-dir "$SOURCE_PERF_DIR" \
|
||||
--output "$SOURCE_PERF_DIR/index.md")
|
||||
@@ -604,7 +621,7 @@ jobs:
|
||||
|
||||
## Source probes
|
||||
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
25
.github/workflows/openclaw-release-checks.yml
vendored
25
.github/workflows/openclaw-release-checks.yml
vendored
@@ -1181,7 +1181,7 @@ jobs:
|
||||
runtime_tool_coverage_release_checks:
|
||||
name: Enforce QA Lab runtime tool coverage
|
||||
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
|
||||
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -1204,13 +1204,35 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download runtime parity status
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/
|
||||
|
||||
- name: Verify runtime parity producer status
|
||||
id: verify_runtime_parity_status
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status_path=".artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env"
|
||||
status="$(sed -n 's/^status=//p' "$status_path" | tail -n 1)"
|
||||
if [[ "$status" != "success" ]]; then
|
||||
echo "Runtime parity producer status is ${status:-missing}; skipping coverage artifact consumer."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download runtime parity artifacts
|
||||
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
- name: Enforce standard runtime tool coverage
|
||||
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa coverage \
|
||||
@@ -1412,7 +1434,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
426
.github/workflows/openclaw-release-publish.yml
vendored
426
.github/workflows/openclaw-release-publish.yml
vendored
@@ -15,6 +15,14 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
|
||||
required: false
|
||||
type: string
|
||||
windows_node_tag:
|
||||
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
|
||||
required: false
|
||||
type: string
|
||||
windows_node_installer_digests:
|
||||
description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests
|
||||
required: false
|
||||
type: string
|
||||
npm_telegram_run_id:
|
||||
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
|
||||
required: false
|
||||
@@ -81,12 +89,15 @@ jobs:
|
||||
outputs:
|
||||
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
|
||||
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
|
||||
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
|
||||
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
@@ -115,6 +126,22 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
stable_release=true
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
|
||||
stable_release=false
|
||||
fi
|
||||
if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
|
||||
echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then
|
||||
echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then
|
||||
echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2
|
||||
exit 1
|
||||
fi
|
||||
tideclaw_alpha_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
@@ -143,6 +170,73 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate stable Windows source release
|
||||
id: windows_source
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
source_json="$(gh release view "${WINDOWS_NODE_TAG}" \
|
||||
--repo openclaw/openclaw-windows-node \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)"
|
||||
if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then
|
||||
echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then
|
||||
echo "Stable OpenClaw publish requires a published Windows source release." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then
|
||||
echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
required_assets=(
|
||||
"OpenClawCompanion-Setup-x64.exe"
|
||||
"OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)"
|
||||
if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" '
|
||||
if type == "object" and
|
||||
(keys | sort) == ($names | sort) and
|
||||
all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$"))
|
||||
then .
|
||||
else error("invalid candidate-approved Windows installer digest map")
|
||||
end
|
||||
')"; then
|
||||
echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
for asset_name in "${required_assets[@]}"; do
|
||||
asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')"
|
||||
asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')"
|
||||
if [[ "${asset_match_count}" != "1" ]]; then
|
||||
echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2
|
||||
exit 1
|
||||
fi
|
||||
asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')"
|
||||
if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
|
||||
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2
|
||||
exit 1
|
||||
fi
|
||||
approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')"
|
||||
if [[ "${asset_digest}" != "${approved_digest}" ]]; then
|
||||
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT"
|
||||
echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Download OpenClaw npm preflight manifest
|
||||
id: preflight_artifact
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
@@ -337,6 +431,7 @@ jobs:
|
||||
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
run: |
|
||||
{
|
||||
echo "### Release target"
|
||||
@@ -347,13 +442,16 @@ jobs:
|
||||
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
|
||||
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
|
||||
fi
|
||||
if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then
|
||||
echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
publish:
|
||||
name: Publish plugins, then OpenClaw
|
||||
needs: [resolve_release_target]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
environment: npm-release
|
||||
steps:
|
||||
- name: Checkout release SHA
|
||||
@@ -383,11 +481,19 @@ jobs:
|
||||
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
|
||||
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
|
||||
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
|
||||
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dispatch_workflow() {
|
||||
is_stable_release() {
|
||||
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
|
||||
}
|
||||
|
||||
dispatch_workflow_at_ref() {
|
||||
local workflow_ref="$1"
|
||||
shift
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
@@ -397,7 +503,7 @@ jobs:
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -432,6 +538,10 @@ jobs:
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
dispatch_workflow() {
|
||||
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
|
||||
}
|
||||
|
||||
print_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
@@ -710,6 +820,71 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_clawhub_release_plan() {
|
||||
local -a plan_args
|
||||
|
||||
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
|
||||
plan_args=(
|
||||
--release-tag "${RELEASE_TAG}"
|
||||
--release-publish-branch "${CHILD_WORKFLOW_REF}"
|
||||
--release-publish-run-id "${GITHUB_RUN_ID}"
|
||||
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
|
||||
)
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
plan_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
|
||||
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
|
||||
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
|
||||
|
||||
echo "Resolved OpenClaw release ClawHub dispatch plan:"
|
||||
cat "${clawhub_plan_path}"
|
||||
|
||||
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
|
||||
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
|
||||
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
|
||||
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
|
||||
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
|
||||
|
||||
{
|
||||
echo "### ClawHub release plan"
|
||||
echo
|
||||
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
|
||||
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
|
||||
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
|
||||
if [[ -n "${normal_plugins}" ]]; then
|
||||
echo "- Normal plugins: \`${normal_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${bootstrap_plugins}" ]]; then
|
||||
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${missing_trusted_plugins}" ]]; then
|
||||
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_clawhub_dispatch_args() {
|
||||
local target="$1"
|
||||
while IFS=$'\t' read -r key value; do
|
||||
clawhub_dispatch_args+=(-f "${key}=${value}")
|
||||
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
|
||||
}
|
||||
|
||||
write_clawhub_runtime_state() {
|
||||
local force_skip_clawhub="$1"
|
||||
local output_path="$2"
|
||||
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
|
||||
--force-skip-clawhub "${force_skip_clawhub}" \
|
||||
--normal-run-id "${plugin_clawhub_run_id:-}" \
|
||||
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
|
||||
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -765,10 +940,105 @@ jobs:
|
||||
}
|
||||
|
||||
publish_github_release() {
|
||||
if is_stable_release; then
|
||||
verify_windows_release_asset_contract
|
||||
fi
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
verify_windows_release_asset_contract() {
|
||||
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
|
||||
# Add future promoted installer names, such as MSIX x64/ARM64, here.
|
||||
local -a installer_assets=(
|
||||
"OpenClawCompanion-Setup-x64.exe"
|
||||
"OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
local -a required_assets=(
|
||||
"${installer_assets[@]}"
|
||||
"OpenClawCompanion-SHA256SUMS.txt"
|
||||
)
|
||||
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)"
|
||||
expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
|
||||
actual_companion_assets="$(printf '%s' "${release_json}" | jq -c '
|
||||
[.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort
|
||||
')"
|
||||
if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then
|
||||
echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2
|
||||
return 1
|
||||
fi
|
||||
for asset_name in "${required_assets[@]}"; do
|
||||
if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
|
||||
echo "Stable release is missing required Windows asset ${asset_name}." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract"
|
||||
manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt"
|
||||
rm -rf "${manifest_dir}"
|
||||
mkdir -p "${manifest_dir}"
|
||||
gh release download "${RELEASE_TAG}" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "OpenClawCompanion-SHA256SUMS.txt" \
|
||||
--dir "${manifest_dir}"
|
||||
if ! manifest_json="$(jq -Rsc '
|
||||
split("\n") as $lines |
|
||||
(if $lines[-1] == "" then $lines[0:-1] else $lines end) |
|
||||
map(sub("\r$"; "")) |
|
||||
if all(.[]; test("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
|
||||
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
|
||||
else error("malformed Windows checksum manifest entry")
|
||||
end
|
||||
' "${manifest_path}")"; then
|
||||
echo "Stable release Windows checksum manifest contains malformed entries." >&2
|
||||
return 1
|
||||
fi
|
||||
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
|
||||
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
|
||||
length == ($expected | length) and
|
||||
([.[].name] | sort) == $expected and
|
||||
([.[].name] | unique | length) == length
|
||||
' >/dev/null; then
|
||||
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
|
||||
return 1
|
||||
fi
|
||||
for asset_name in "${installer_assets[@]}"; do
|
||||
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
|
||||
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
|
||||
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
|
||||
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
|
||||
return 1
|
||||
fi
|
||||
expected_hash="${expected_digest#sha256:}"
|
||||
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
|
||||
any(.[]; .name == $name and .hash == $hash)
|
||||
' >/dev/null; then
|
||||
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
promote_windows_release_assets() {
|
||||
if ! is_stable_release; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
|
||||
echo "Stable release is missing prevalidated Windows installer digests." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
windows_node_run_id="$(dispatch_workflow windows-node-release.yml \
|
||||
-f tag="${RELEASE_TAG}" \
|
||||
-f windows_node_tag="${WINDOWS_NODE_TAG}" \
|
||||
-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")"
|
||||
echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
wait_for_run windows-node-release.yml "${windows_node_run_id}"
|
||||
}
|
||||
|
||||
upload_dependency_evidence_release_asset() {
|
||||
local release_version download_dir asset_path asset_name artifact_name
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -798,7 +1068,7 @@ jobs:
|
||||
}
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path skip_clawhub
|
||||
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
|
||||
local -a verify_args
|
||||
|
||||
skip_clawhub="${1:-false}"
|
||||
@@ -815,17 +1085,18 @@ jobs:
|
||||
--dist-tag "${RELEASE_NPM_DIST_TAG}"
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--clawhub-workflow-ref "${clawhub_workflow_ref}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
--skip-github-release
|
||||
)
|
||||
if [[ "${skip_clawhub}" == "true" || "${WAIT_FOR_CLAWHUB}" != "true" ]]; then
|
||||
verify_args+=(--skip-clawhub)
|
||||
else
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
fi
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
|
||||
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
|
||||
while IFS= read -r arg; do
|
||||
verify_args+=("${arg}")
|
||||
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
@@ -841,7 +1112,7 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
@@ -855,10 +1126,13 @@ jobs:
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
|
||||
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
|
||||
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
|
||||
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
|
||||
windows_line=""
|
||||
if [[ -n "${windows_node_run_id// }" ]]; then
|
||||
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
@@ -875,7 +1149,9 @@ jobs:
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
WINDOWS_LINE="${windows_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
@@ -899,8 +1175,10 @@ jobs:
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
process.env.CLAWHUB_BOOTSTRAP_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
|
||||
].join("\n");
|
||||
|
||||
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
|
||||
@@ -915,6 +1193,7 @@ jobs:
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
@@ -924,6 +1203,9 @@ jobs:
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- Windows Hub promotion: required before the GitHub release can be published"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "- Workflow completion waits for ClawHub"
|
||||
else
|
||||
@@ -933,27 +1215,66 @@ jobs:
|
||||
|
||||
guard_existing_public_release
|
||||
guard_openclaw_npm_not_already_published
|
||||
resolve_clawhub_release_plan
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
fi
|
||||
|
||||
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
|
||||
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
|
||||
plugin_clawhub_run_id=""
|
||||
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args normal
|
||||
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
plugin_clawhub_bootstrap_run_id=""
|
||||
plugin_clawhub_bootstrap_completed="false"
|
||||
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args bootstrap
|
||||
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
{
|
||||
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
|
||||
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
|
||||
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
|
||||
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
plugin_clawhub_bootstrap_completed="true"
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
openclaw_npm_run_id=""
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
|
||||
@@ -970,19 +1291,52 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
clawhub_bootstrap_result=""
|
||||
clawhub_bootstrap_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
|
||||
clawhub_bootstrap_pid="${wait_run_pid}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -996,6 +1350,7 @@ jobs:
|
||||
|
||||
failed=0
|
||||
openclaw_failed=0
|
||||
windows_node_run_id=""
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
@@ -1011,6 +1366,12 @@ jobs:
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
@@ -1020,6 +1381,9 @@ jobs:
|
||||
fi
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
if ! promote_windows_release_assets; then
|
||||
failed=1
|
||||
fi
|
||||
append_release_proof_to_github_release
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
publish_github_release
|
||||
|
||||
504
.github/workflows/plugin-clawhub-new.yml
vendored
Normal file
504
.github/workflows/plugin-clawhub-new.yml
vendored
Normal file
@@ -0,0 +1,504 @@
|
||||
name: Plugin ClawHub New
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to bootstrap on ClawHub
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
|
||||
jobs:
|
||||
resolve_bootstrap_plan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
if [[ -n "${TARGET_REF}" ]]; then
|
||||
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
|
||||
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
|
||||
else
|
||||
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "${target_sha}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r release_ref; do
|
||||
if git merge-base --is-ancestor HEAD "${release_ref}"; then
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
|
||||
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
|
||||
|
||||
- name: Resolve plugin bootstrap plan
|
||||
id: plan
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts \
|
||||
--selection-mode selected \
|
||||
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
|
||||
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
|
||||
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
|
||||
matrix_json="$(
|
||||
jq -c '
|
||||
[
|
||||
.bootstrapCandidates[]? + {
|
||||
bootstrapMode: "publish",
|
||||
requiresManualOverride: false
|
||||
},
|
||||
.missingTrustedPublisher[]? + {
|
||||
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
|
||||
requiresManualOverride: true
|
||||
}
|
||||
]
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
|
||||
invalid_scope="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.packageName | startswith("@openclaw/") | not)
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid_scope}" ]]; then
|
||||
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
|
||||
printf '%s\n' "${invalid_scope}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
not_bootstrap="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
|
||||
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
|
||||
| .all[]?
|
||||
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${not_bootstrap}" ]]; then
|
||||
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
|
||||
printf '%s\n' "${not_bootstrap}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
|
||||
echo "No selected packages require ClawHub bootstrap." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "ClawHub bootstrap candidates:"
|
||||
jq -r '
|
||||
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
echo "ClawHub trusted-publisher repair candidates:"
|
||||
jq -r '
|
||||
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.publishTag != "alpha" or .channel != "alpha")
|
||||
| "- \(.packageName)@\(.version) [\(.publishTag)]"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: resolve_bootstrap_plan
|
||||
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
validate_bootstrap_trusted_publisher_cli:
|
||||
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate pinned ClawHub trusted publisher CLI support
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
help_output="$(
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set --help 2>&1 || true
|
||||
)"
|
||||
printf '%s\n' "${help_output}"
|
||||
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
|
||||
exit 1
|
||||
fi
|
||||
for required_flag in --repository --workflow-filename; do
|
||||
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
publish_bootstrap_plugins:
|
||||
needs:
|
||||
[
|
||||
resolve_bootstrap_plan,
|
||||
validate_release_publish_approval,
|
||||
validate_bootstrap_trusted_publisher_cli,
|
||||
]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-bootstrap
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
EOF
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Write ClawHub token config
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
config_path="${RUNNER_TEMP}/clawhub-config.json"
|
||||
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const registry = process.env.CLAWHUB_REGISTRY?.trim();
|
||||
const token = process.env.CLAWHUB_TOKEN?.trim();
|
||||
const configPath = process.env.CONFIG_PATH;
|
||||
if (!registry) {
|
||||
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!configPath) {
|
||||
throw new Error("CONFIG_PATH is required.");
|
||||
}
|
||||
|
||||
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
NODE
|
||||
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Publish ClawHub bootstrap package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
|
||||
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
|
||||
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
|
||||
elif [[ "${DRY_RUN}" == "true" ]]; then
|
||||
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
else
|
||||
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
|
||||
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
|
||||
fi
|
||||
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
fi
|
||||
|
||||
- name: Configure trusted publisher for normal OIDC releases
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
|
||||
--repository openclaw/openclaw \
|
||||
--workflow-filename plugin-clawhub-release.yml
|
||||
|
||||
verify_bootstrap_clawhub_package:
|
||||
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify bootstrap ClawHub package and trusted publisher
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'EOF'
|
||||
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
|
||||
const packageName = process.env.PACKAGE_NAME;
|
||||
const packageVersion = process.env.PACKAGE_VERSION;
|
||||
const packageTag = process.env.PACKAGE_TAG;
|
||||
if (!packageName || !packageVersion || !packageTag) {
|
||||
throw new Error("Missing ClawHub bootstrap verification env.");
|
||||
}
|
||||
const encodedName = encodeURIComponent(packageName);
|
||||
const encodedVersion = encodeURIComponent(packageVersion);
|
||||
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
|
||||
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
|
||||
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
|
||||
const artifactUrl = `${versionUrl}/artifact/download`;
|
||||
|
||||
async function fetchWithRetry(url, options = {}) {
|
||||
let lastStatus = "unknown";
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual", ...options });
|
||||
lastStatus = response.status;
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastStatus = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
|
||||
}
|
||||
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
|
||||
}
|
||||
|
||||
const detailResponse = await fetchWithRetry(detailUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
|
||||
}
|
||||
const detail = await detailResponse.json();
|
||||
const tags = detail?.package?.tags ?? {};
|
||||
if (tags[packageTag] !== packageVersion) {
|
||||
throw new Error(
|
||||
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!trustedPublisherResponse.ok) {
|
||||
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
|
||||
}
|
||||
const trustedPublisherDetail = await trustedPublisherResponse.json();
|
||||
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
|
||||
if (
|
||||
trustedPublisher?.repository !== "openclaw/openclaw" ||
|
||||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
|
||||
trustedPublisher?.environment != null
|
||||
) {
|
||||
throw new Error(
|
||||
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionResponse = await fetchWithRetry(versionUrl);
|
||||
if (!versionResponse.ok) {
|
||||
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
|
||||
}
|
||||
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
|
||||
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
|
||||
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
|
||||
}
|
||||
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
|
||||
EOF
|
||||
258
.github/workflows/plugin-clawhub-release.yml
vendored
258
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -24,6 +24,10 @@ on:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the full ClawHub artifact handoff without publishing.
|
||||
required: false
|
||||
@@ -38,9 +42,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -50,9 +52,15 @@ jobs:
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
|
||||
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -83,9 +91,27 @@ jobs:
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate OIDC source matches workflow ref
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
|
||||
exit 0
|
||||
fi
|
||||
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
|
||||
echo "The ref input is only supported for dry_run=true." >&2
|
||||
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
@@ -96,8 +122,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
@@ -158,36 +184,78 @@ jobs:
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
|
||||
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
has_missing_trusted_publisher="false"
|
||||
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
|
||||
has_missing_trusted_publisher="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
|
||||
echo "skipped_published_count=${skipped_published_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
|
||||
echo "matrix=${matrix_json}"
|
||||
echo "bootstrap_matrix=${bootstrap_matrix_json}"
|
||||
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Bootstrap candidates requiring token bootstrap:"
|
||||
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Missing trusted publisher candidates:"
|
||||
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Fail when trusted publisher is missing
|
||||
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
|
||||
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail normal publish when bootstrap is required
|
||||
if: steps.plan.outputs.bootstrap_candidate_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
|
||||
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail manual publish when target versions already exist
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
@@ -197,12 +265,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify OpenClaw ClawHub package ownership
|
||||
if: steps.plan.outputs.has_candidates == 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
@@ -221,7 +283,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
@@ -240,99 +302,8 @@ jobs:
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview publish command
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
pack_plugins_clawhub_artifacts:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
needs: [preview_plugins_clawhub, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -367,47 +338,19 @@ jobs:
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Pack ClawHub package artifact
|
||||
env:
|
||||
@@ -428,19 +371,23 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugin_clawhub_release:
|
||||
approve_plugins_clawhub_release:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Approve ClawHub package publish
|
||||
run: echo "ClawHub package publish approved."
|
||||
- name: Approve Plugin ClawHub release publish
|
||||
run: |
|
||||
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
|
||||
needs:
|
||||
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -450,19 +397,18 @@ jobs:
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
|
||||
with:
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
json: true
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
registry: https://clawhub.ai
|
||||
site: https://clawhub.ai
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
source_repo: ${{ github.repository }}
|
||||
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
source_ref: ${{ github.ref }}
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
source_path: ${{ matrix.plugin.packageDir }}
|
||||
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
|
||||
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
|
||||
1
.github/workflows/plugin-npm-release.yml
vendored
1
.github/workflows/plugin-npm-release.yml
vendored
@@ -288,6 +288,7 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Verify published runtime
|
||||
|
||||
@@ -532,7 +532,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
11
.github/workflows/stale.yml
vendored
11
.github/workflows/stale.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
days-before-pr-close: 7
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
days-before-pr-close: 7
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
@@ -277,6 +277,9 @@ jobs:
|
||||
"security",
|
||||
"no-stale",
|
||||
"bad-barnacle",
|
||||
"clawsweeper:queueable-fix",
|
||||
"clawsweeper:source-repro",
|
||||
"clawsweeper:fix-shape-clear",
|
||||
]);
|
||||
const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
|
||||
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
|
||||
223
.github/workflows/windows-node-release.yml
vendored
223
.github/workflows/windows-node-release.yml
vendored
@@ -8,9 +8,12 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
windows_node_tag:
|
||||
description: openclaw-windows-node release tag to promote, or latest
|
||||
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
|
||||
required: true
|
||||
type: string
|
||||
expected_installer_digests:
|
||||
description: Compact JSON map of installer asset names to pinned source sha256 digests
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
@@ -31,46 +34,129 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
|
||||
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
|
||||
}
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
|
||||
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
|
||||
$stableRelease = -not (
|
||||
$env:RELEASE_TAG.Contains("-alpha.") -or
|
||||
$env:RELEASE_TAG.Contains("-beta.")
|
||||
)
|
||||
if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') {
|
||||
throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG"
|
||||
}
|
||||
|
||||
try {
|
||||
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
|
||||
} catch {
|
||||
throw "expected_installer_digests must be a JSON object: $_"
|
||||
}
|
||||
# Add future signed installer names, such as MSIX x64/ARM64, here.
|
||||
$requiredInstallerNames = @(
|
||||
"OpenClawCompanion-Setup-x64.exe",
|
||||
"OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
$allowedTargetCompanionAssetNames = @(
|
||||
$requiredInstallerNames
|
||||
"OpenClawCompanion-SHA256SUMS.txt"
|
||||
)
|
||||
if ($expectedDigests.Count -ne $requiredInstallerNames.Count) {
|
||||
throw "expected_installer_digests must contain exactly the current installer asset contract."
|
||||
}
|
||||
foreach ($name in $requiredInstallerNames) {
|
||||
$digest = [string]$expectedDigests[$name]
|
||||
if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') {
|
||||
throw "expected_installer_digests is missing a valid pinned digest for $name."
|
||||
}
|
||||
}
|
||||
|
||||
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
|
||||
if ($targetRelease.tagName -ne $env:RELEASE_TAG) {
|
||||
throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)"
|
||||
}
|
||||
$unexpectedTargetCompanionAssets = @(
|
||||
$targetRelease.assets |
|
||||
Where-Object {
|
||||
$_.name.StartsWith("OpenClawCompanion-") -and
|
||||
$_.name -notin $allowedTargetCompanionAssetNames
|
||||
} |
|
||||
ForEach-Object name |
|
||||
Sort-Object
|
||||
)
|
||||
if ($unexpectedTargetCompanionAssets.Count -ne 0) {
|
||||
throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')"
|
||||
}
|
||||
|
||||
$sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
|
||||
if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) {
|
||||
throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)"
|
||||
}
|
||||
if ($sourceRelease.isDraft) {
|
||||
throw "Windows source release must be published: $($sourceRelease.url)"
|
||||
}
|
||||
if ($stableRelease -and $sourceRelease.isPrerelease) {
|
||||
throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)"
|
||||
}
|
||||
foreach ($name in $requiredInstallerNames) {
|
||||
$sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name)
|
||||
if ($sourceAssets.Count -ne 1) {
|
||||
throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)."
|
||||
}
|
||||
if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) {
|
||||
throw "Windows source release asset digest does not match the pinned digest: $name"
|
||||
}
|
||||
}
|
||||
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
|
||||
|
||||
- name: Download Windows Hub release installers
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
$tagArgs = @()
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest") {
|
||||
$tagArgs += $env:WINDOWS_NODE_TAG
|
||||
}
|
||||
gh release download @tagArgs `
|
||||
--repo openclaw/openclaw-windows-node `
|
||||
--pattern "OpenClawCompanion-Setup-*.exe" `
|
||||
--dir dist
|
||||
|
||||
$expected = @(
|
||||
"dist/OpenClawCompanion-Setup-x64.exe",
|
||||
"dist/OpenClawCompanion-Setup-arm64.exe"
|
||||
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
|
||||
# Every matched installer is signature-checked, checksummed, and promoted.
|
||||
$installerPatterns = @(
|
||||
"OpenClawCompanion-Setup-x64.exe",
|
||||
"OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
foreach ($file in $expected) {
|
||||
if (-not (Test-Path -LiteralPath $file)) {
|
||||
throw "Missing expected Windows installer: $file"
|
||||
$downloadArgs = @(
|
||||
$env:WINDOWS_NODE_TAG,
|
||||
"--repo", "openclaw/openclaw-windows-node",
|
||||
"--dir", "dist"
|
||||
)
|
||||
foreach ($pattern in $installerPatterns) {
|
||||
$downloadArgs += @("--pattern", $pattern)
|
||||
}
|
||||
gh release download @downloadArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
|
||||
}
|
||||
|
||||
foreach ($pattern in $installerPatterns) {
|
||||
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
|
||||
if ($patternMatches.Count -ne 1) {
|
||||
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
|
||||
}
|
||||
}
|
||||
|
||||
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
|
||||
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
|
||||
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
|
||||
$expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"
|
||||
Get-ChildItem -LiteralPath dist -File | ForEach-Object {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "$($_.Name) Authenticode signature was $($signature.Status)."
|
||||
@@ -78,6 +164,9 @@ jobs:
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "$($_.Name) has no signer certificate."
|
||||
}
|
||||
if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) {
|
||||
throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)."
|
||||
}
|
||||
[pscustomobject]@{
|
||||
File = $_.Name
|
||||
Signer = $signature.SignerCertificate.Subject
|
||||
@@ -88,7 +177,7 @@ jobs:
|
||||
- name: Write SHA-256 manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
|
||||
Get-ChildItem -LiteralPath dist -File |
|
||||
Sort-Object Name |
|
||||
ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
|
||||
@@ -101,12 +190,81 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload $env:RELEASE_TAG `
|
||||
dist/OpenClawCompanion-Setup-x64.exe `
|
||||
dist/OpenClawCompanion-Setup-arm64.exe `
|
||||
dist/OpenClawCompanion-SHA256SUMS.txt `
|
||||
--repo $env:GITHUB_REPOSITORY `
|
||||
--clobber
|
||||
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
|
||||
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
|
||||
}
|
||||
|
||||
- name: Verify promoted release asset contract
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path verified | Out-Null
|
||||
$expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name)
|
||||
$expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object)
|
||||
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
|
||||
$actualCompanionAssetNames = @(
|
||||
$targetRelease.assets |
|
||||
Where-Object { $_.name.StartsWith("OpenClawCompanion-") } |
|
||||
ForEach-Object name |
|
||||
Sort-Object
|
||||
)
|
||||
$assetContractDiff = @(
|
||||
Compare-Object `
|
||||
-ReferenceObject $expectedCompanionAssetNames `
|
||||
-DifferenceObject $actualCompanionAssetNames
|
||||
)
|
||||
if (
|
||||
$actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or
|
||||
$assetContractDiff.Count -ne 0
|
||||
) {
|
||||
throw "Promoted OpenClawCompanion asset names do not exactly match the current contract."
|
||||
}
|
||||
|
||||
foreach ($asset in $expectedAssets) {
|
||||
gh release download $env:RELEASE_TAG `
|
||||
--repo $env:GITHUB_REPOSITORY `
|
||||
--pattern $asset.Name `
|
||||
--dir verified
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to download promoted Windows release asset $($asset.Name)."
|
||||
}
|
||||
}
|
||||
|
||||
$manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt"
|
||||
$manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object {
|
||||
if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') {
|
||||
throw "Invalid Windows SHA-256 manifest entry: $_"
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Hash = $Matches[1]
|
||||
Name = $Matches[2]
|
||||
}
|
||||
})
|
||||
$expectedInstallerNames = @(
|
||||
$expectedAssets |
|
||||
Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" |
|
||||
ForEach-Object Name
|
||||
)
|
||||
$manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object)
|
||||
$contractDiff = @(
|
||||
Compare-Object `
|
||||
-ReferenceObject $expectedInstallerNames `
|
||||
-DifferenceObject $manifestInstallerNames
|
||||
)
|
||||
if ($contractDiff.Count -ne 0) {
|
||||
throw "Promoted Windows SHA-256 manifest does not match the installer asset contract."
|
||||
}
|
||||
|
||||
foreach ($entry in $manifestEntries) {
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash
|
||||
if ($hash -ne $entry.Hash) {
|
||||
throw "Promoted Windows release asset checksum mismatch: $($entry.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Summary
|
||||
shell: pwsh
|
||||
@@ -119,8 +277,9 @@ jobs:
|
||||
|
||||
OpenClaw release: $env:RELEASE_TAG
|
||||
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
|
||||
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
|
||||
"@ >> $env:GITHUB_STEP_SUMMARY
|
||||
Get-ChildItem -LiteralPath dist -File |
|
||||
Sort-Object Name |
|
||||
ForEach-Object {
|
||||
"- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)"
|
||||
} >> $env:GITHUB_STEP_SUMMARY
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -127,6 +127,8 @@ mantis/
|
||||
!.agents/skills/clawdtributor/**
|
||||
!.agents/skills/control-ui-e2e/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/discord-user-post/
|
||||
!.agents/skills/discord-user-post/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/technical-documentation/
|
||||
|
||||
@@ -214,6 +214,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults.
|
||||
- Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs.
|
||||
- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios/<theme>/*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`.
|
||||
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
|
||||
- Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`.
|
||||
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,6 +2,38 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
### Highlights
|
||||
|
||||
- Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.
|
||||
- Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, restart shutdown aborts, yielded subagent pauses, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #91357, #92631, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.
|
||||
- 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, bounded model browse discovery, storeless OpenAI Responses replay gating, and Claude 4.5 Copilot tool-streaming safety. (#92796, #90116, #92627, #91218, #90686, #92247, #90706, #75393) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @samson910022, @snowzlm, and @Kailigithub.
|
||||
- `/usage` 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.
|
||||
- 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.
|
||||
- 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, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, and @yhterrance.
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
- Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including rich prompt handoff to CLI backends and transport fixtures for richer drafts. (#92679, #92513) Thanks @obviyus and @TurboTheTurtle.
|
||||
- Agent commands: support `/btw` in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
|
||||
- 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.
|
||||
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
|
||||
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, 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, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
|
||||
- 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, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
|
||||
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
|
||||
- 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, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
|
||||
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
|
||||
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
|
||||
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
### Highlights
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
|
||||
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
|
||||
fi && \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
|
||||
mkdir -p dist/extensions/qa-lab/web && \
|
||||
rm -rf dist/extensions/qa-lab/web/dist && \
|
||||
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
|
||||
fi
|
||||
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
@@ -130,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
|
||||
# installed prod graph in the same step that runs offline prune.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
@@ -139,6 +147,10 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
rm -rf \
|
||||
/app/node_modules/openclaw \
|
||||
/app/node_modules/.bin/openclaw \
|
||||
/app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
# ── Runtime base image ──────────────────────────────────────────
|
||||
|
||||
@@ -443,6 +443,7 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -485,6 +486,14 @@ class NodeRuntime(
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun subscribeOperatorSessionEvents() {
|
||||
try {
|
||||
operatorSession.request("sessions.subscribe", null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
|
||||
@@ -311,7 +311,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -321,7 +320,6 @@ class ChatController(
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
@@ -332,6 +330,17 @@ class ChatController(
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"sessions.changed" -> {
|
||||
if (payloadJson.isNullOrBlank()) {
|
||||
refreshSessionsForCurrentWindow()
|
||||
} else {
|
||||
handleSessionsChangedEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
"session.message" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleSessionMessageEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
@@ -353,6 +362,7 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -388,6 +398,10 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSessionsForCurrentWindow() {
|
||||
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
@@ -457,6 +471,7 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -472,6 +487,31 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionsChangedEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
if (payload["reason"].asStringOrNull() == "delete") {
|
||||
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
|
||||
return
|
||||
}
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
} else {
|
||||
refreshSessionsForCurrentWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionMessageEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
@@ -600,6 +640,7 @@ class ChatController(
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
@@ -622,20 +663,69 @@ class ChatController(
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
sessionInfo = sessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
|
||||
}
|
||||
|
||||
private fun parseSessionEntry(
|
||||
obj: JsonObject?,
|
||||
fallbackKey: String? = null,
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
updatedAtMs = obj["updatedAt"].asLongOrNull(),
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim(),
|
||||
totalTokens = obj["totalTokens"].asLongOrNull(),
|
||||
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
|
||||
contextTokens = obj["contextTokens"].asLongOrNull(),
|
||||
hasContextUsageMetadata =
|
||||
"totalTokens" in obj ||
|
||||
"totalTokensFresh" in obj ||
|
||||
"contextTokens" in obj,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateSessionFromHistory(history: ChatHistory) {
|
||||
val info = history.sessionInfo ?: return
|
||||
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
|
||||
}
|
||||
|
||||
private fun upsertSessionEntry(
|
||||
entry: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
) {
|
||||
val current = _sessions.value
|
||||
val index = current.indexOfFirst { it.key == entry.key }
|
||||
_sessions.value =
|
||||
if (index >= 0) {
|
||||
current.toMutableList().also {
|
||||
it[index] =
|
||||
mergeChatSessionEntry(
|
||||
existing = it[index],
|
||||
next = entry,
|
||||
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
listOf(entry) + current
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSessionEntry(sessionKey: String?) {
|
||||
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
@@ -857,3 +947,44 @@ private fun JsonElement?.asLongOrNull(): Long? =
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toBooleanStrictOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun mergeChatSessionEntry(
|
||||
existing: ChatSessionEntry,
|
||||
next: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
): ChatSessionEntry {
|
||||
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
|
||||
return existing.copy(
|
||||
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
|
||||
displayName = next.displayName ?: existing.displayName,
|
||||
totalTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokens
|
||||
next.hasContextUsageMetadata -> next.totalTokens
|
||||
else -> null
|
||||
},
|
||||
totalTokensFresh =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokensFresh
|
||||
next.hasContextUsageMetadata -> next.totalTokensFresh
|
||||
else -> null
|
||||
},
|
||||
contextTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
|
||||
next.hasContextUsageMetadata -> next.contextTokens
|
||||
else -> null
|
||||
},
|
||||
hasContextUsageMetadata =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
|
||||
else -> next.hasContextUsageMetadata
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
val totalTokens: Long? = null,
|
||||
val totalTokensFresh: Boolean? = null,
|
||||
val contextTokens: Long? = null,
|
||||
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,7 @@ data class ChatHistory(
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
val sessionInfo: ChatSessionEntry? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
|
||||
@Composable
|
||||
@@ -95,6 +96,7 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -196,6 +198,7 @@ fun ChatScreen(
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
@@ -685,6 +688,7 @@ private fun ChatComposer(
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
@@ -699,7 +703,11 @@ private fun ChatComposer(
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
ChatContextMeter(
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
@@ -735,8 +743,10 @@ private fun ChatComposer(
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -755,7 +765,13 @@ private fun ChatContextMeter(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -768,7 +784,7 @@ private fun ChatContextMeter(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.fillMaxWidth(contextFraction)
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
@@ -902,6 +918,32 @@ private fun isActiveSessionChoice(
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
internal data class ChatContextUsage(
|
||||
val totalTokens: Long?,
|
||||
val totalTokensFresh: Boolean?,
|
||||
val contextTokens: Long?,
|
||||
)
|
||||
|
||||
internal fun resolveChatContextUsage(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): ChatContextUsage {
|
||||
val entry =
|
||||
sessions.firstOrNull {
|
||||
isActiveSessionChoice(
|
||||
choiceKey = it.key,
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
contextTokens = entry?.contextTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
@@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String =
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Maps thinking presets to the visual context meter fill fraction. */
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
|
||||
if (usage.totalTokensFresh == false) return null
|
||||
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
|
||||
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
|
||||
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
internal fun contextMeterLabel(
|
||||
usage: ChatContextUsage,
|
||||
thinkingLevel: String,
|
||||
): String {
|
||||
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
|
||||
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
|
||||
}
|
||||
|
||||
internal fun contextMeterThinkingLabel(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
|
||||
@@ -59,4 +59,96 @@ class ChatControllerSessionPolicyTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
hasContextUsageMetadata = false,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals("agent:main:phone", merged.key)
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(null, merged.totalTokens)
|
||||
assertEquals(null, merged.totalTokensFresh)
|
||||
assertEquals(null, merged.contextTokens)
|
||||
assertFalse(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 120_000L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeChatSessionEntry(
|
||||
existing = existing,
|
||||
next = next,
|
||||
preserveExistingContextUsageWithoutTotal = true,
|
||||
)
|
||||
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(41_000L, merged.totalTokens)
|
||||
assertEquals(true, merged.totalTokensFresh)
|
||||
assertEquals(120_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
totalTokens = 82_000L,
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals(82_000L, merged.totalTokens)
|
||||
assertEquals(false, merged.totalTokensFresh)
|
||||
assertEquals(100_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ChatContextMeterTest {
|
||||
@Test
|
||||
fun contextMeterUsesActiveSessionTokenBudget() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:mobile:test-device",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 1_250L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 5_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "agent:main:mobile:test-device",
|
||||
mainSessionKey = "main",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
|
||||
assertEquals(0.25f, contextMeterWidth(usage))
|
||||
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterResolvesCanonicalMainAlias() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:node-phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Main",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:main:node-phone",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
|
||||
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
|
||||
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterClampsOverfullSessions() {
|
||||
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
|
||||
|
||||
assertEquals(1.0f, contextMeterWidth(usage))
|
||||
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotDisplayStaleTokenUsage() {
|
||||
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
}
|
||||
18
apps/ios/.periphery.yml
Normal file
18
apps/ios/.periphery.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
project: OpenClaw.xcodeproj
|
||||
schemes:
|
||||
- OpenClaw
|
||||
retain_codable_properties: true
|
||||
retain_swift_ui_previews: true
|
||||
retain_objc_accessible: true
|
||||
retain_unused_protocol_func_params: true
|
||||
retain_assign_only_properties: true
|
||||
relative_results: true
|
||||
disable_update_check: true
|
||||
report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchExtension/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.12 - 2026-05-12
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.10 - 2026-05-10
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
|
||||
|
||||
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.19 - 2026-04-19
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.18 - 2026-04-18
|
||||
|
||||
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.14 - 2026-04-14
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.12 - 2026-04-12
|
||||
|
||||
|
||||
53
apps/ios/Config/AppStoreSigning.json
Normal file
53
apps/ios/Config/AppStoreSigning.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"teamId": "FWJYW4S8P8",
|
||||
"signingRepo": "git@github.com:openclaw/ios-signing.git",
|
||||
"certificateType": "IOS_DISTRIBUTION",
|
||||
"profileType": "IOS_APP_STORE",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
"displayName": "OpenClaw",
|
||||
"bundleId": "ai.openclawfoundation.app",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
"displayName": "OpenClaw Share",
|
||||
"bundleId": "ai.openclawfoundation.app.share",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
"displayName": "OpenClaw Activity Widget",
|
||||
"bundleId": "ai.openclawfoundation.app.activitywidget",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchApp",
|
||||
"displayName": "OpenClaw Watch App",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
// Shared iOS signing defaults for local development + CI.
|
||||
#include "Version.xcconfig"
|
||||
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
|
||||
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -18,7 +20,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
#include? "../LocalSigning.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
|
||||
|
||||
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// This file is only an example and should stay committed.
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
|
||||
|
||||
## Distribution Status
|
||||
|
||||
- Public distribution: not available.
|
||||
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
|
||||
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
|
||||
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
|
||||
- Local/manual deploy from source via Xcode remains the default development path.
|
||||
|
||||
## Super-Alpha Disclaimer
|
||||
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
|
||||
pnpm ios:open
|
||||
```
|
||||
|
||||
## Local Beta Release Flow
|
||||
## App Store Release Flow
|
||||
|
||||
Prereqs:
|
||||
|
||||
@@ -55,51 +55,82 @@ Prereqs:
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- `fastlane`
|
||||
- Apple account signed into Xcode for automatic signing/provisioning
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
|
||||
- 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`)
|
||||
- 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
|
||||
|
||||
Release behavior:
|
||||
|
||||
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- 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.
|
||||
- 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.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
- The pinned iOS version must use CalVer like `2026.4.10`.
|
||||
- That pinned value becomes:
|
||||
- `CFBundleShortVersionString = 2026.4.10`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for beta builds:
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
|
||||
Prepare the generated release xcconfig/project without archiving:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:prepare -- --build-number 7
|
||||
```
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta:archive
|
||||
pnpm ios:release:archive
|
||||
```
|
||||
|
||||
Archive and upload to TestFlight:
|
||||
Archive and upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
If you need to force a specific build number:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta -- --build-number 7
|
||||
pnpm ios:release:upload -- --build-number 7
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
|
||||
|
||||
1. Confirm Fastlane auth is set up:
|
||||
|
||||
@@ -119,38 +150,50 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
|
||||
- `ai.openclawfoundation.app`
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `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.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload the beta:
|
||||
6. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
6. Expected behavior:
|
||||
7. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next TestFlight build number for that short version
|
||||
- generates `apps/ios/build/BetaRelease.xcconfig`
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- uploads the IPA to TestFlight
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -176,7 +219,7 @@ Recommended flow:
|
||||
1. Keep `apps/ios/version.json` pinned to the current train version.
|
||||
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
|
||||
3. Run `pnpm ios:version:sync` after changelog changes.
|
||||
4. Upload more TestFlight builds with `pnpm ios:beta`.
|
||||
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
|
||||
5. Let Fastlane bump only the numeric build number.
|
||||
|
||||
### Starting the next production release train
|
||||
@@ -189,7 +232,7 @@ pnpm ios:version:pin -- --from-gateway
|
||||
|
||||
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
|
||||
3. Run `pnpm ios:version:sync`.
|
||||
4. Submit the first TestFlight build for that newly pinned version.
|
||||
4. Submit the first App Store Connect build for that newly pinned version.
|
||||
5. Keep iterating on that same version until the release candidate is ready.
|
||||
|
||||
See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
@@ -197,9 +240,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
## APNs Expectations For Local/Manual Builds
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
@@ -319,7 +362,7 @@ Automatic wake/reconnect hardening:
|
||||
5. If network path is unclear:
|
||||
- switch to manual host/port + TLS in Gateway Advanced settings
|
||||
6. In Xcode console, filter for subsystem/category signals:
|
||||
- `ai.openclaw.ios`
|
||||
- `ai.openclawfoundation.app`
|
||||
- `GatewayDiag`
|
||||
- `APNs registration failed`
|
||||
7. Validate background expectations:
|
||||
|
||||
@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
|
||||
var attachments: [ShareAttachment]
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
|
||||
private var statusLabel: UILabel?
|
||||
private let draftTextView = UITextView()
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
#include "Config/Version.xcconfig"
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
|
||||
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -14,7 +14,50 @@ enum AppleReviewDemoMode {
|
||||
}
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
[
|
||||
LocalChatFixture.appleReviewDemo.agents
|
||||
}
|
||||
}
|
||||
|
||||
enum ScreenshotFixtureMode {
|
||||
static let gatewayName = "OpenClaw Gateway"
|
||||
static let gatewayAddress = "Mac Studio on local network"
|
||||
static let gatewayID = "screenshot-fixture-gateway"
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
LocalChatFixture.appScreenshots.agents
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalChatFixture {
|
||||
let sessionKey: String
|
||||
let sessionIDPrefix: String
|
||||
let displayName: String
|
||||
let subject: String
|
||||
let workspace: String
|
||||
let modelProvider: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let responsePrefix: String
|
||||
let seedMessages: [String]
|
||||
let agents: [AgentSummary]
|
||||
|
||||
static let appleReviewDemo = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "apple-review-demo",
|
||||
displayName: "Apple Review Demo",
|
||||
subject: "Gateway review flow",
|
||||
workspace: "Apple Review Demo",
|
||||
modelProvider: "demo",
|
||||
modelID: "local-demo",
|
||||
modelName: "Apple Review Demo",
|
||||
responsePrefix: "Demo mode is active.",
|
||||
seedMessages: [
|
||||
"""
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Main",
|
||||
@@ -25,12 +68,70 @@ enum AppleReviewDemoMode {
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium"],
|
||||
thinkingdefault: "auto"),
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
static let appScreenshots = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "screenshot-fixture",
|
||||
displayName: "Molty",
|
||||
subject: "Mobile command center",
|
||||
workspace: "OpenClaw",
|
||||
modelProvider: "openai",
|
||||
modelID: "gpt-5.5",
|
||||
modelName: "GPT-5.5",
|
||||
responsePrefix: "OpenClaw is connected to your gateway.",
|
||||
seedMessages: [
|
||||
"""
|
||||
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
|
||||
actions from your phone.
|
||||
""",
|
||||
"""
|
||||
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
|
||||
across the app.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Molty",
|
||||
identity: ["emoji": AnyCodable("M")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
AgentSummary(
|
||||
id: "research",
|
||||
name: "Research",
|
||||
identity: ["emoji": AnyCodable("RS")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "medium"),
|
||||
AgentSummary(
|
||||
id: "automation",
|
||||
name: "Automation",
|
||||
identity: ["emoji": AnyCodable("AU")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
])
|
||||
}
|
||||
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let store = AppleReviewDemoChatStore()
|
||||
struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
private let fixture: LocalChatFixture
|
||||
private let store: LocalFixtureChatStore
|
||||
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.store = LocalFixtureChatStore(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
@@ -47,9 +148,9 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "local-demo",
|
||||
name: "Apple Review Demo",
|
||||
provider: "demo",
|
||||
modelID: self.fixture.modelID,
|
||||
name: self.fixture.modelName,
|
||||
provider: self.fixture.modelProvider,
|
||||
contextWindow: 128_000),
|
||||
]
|
||||
}
|
||||
@@ -101,26 +202,102 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func compactSession(sessionKey _: String) async throws {}
|
||||
}
|
||||
|
||||
private actor AppleReviewDemoChatStore {
|
||||
private let sessionKey = "main"
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.transport.requestHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
try await self.transport.listModels()
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.transport.listSessions(limit: limit)
|
||||
}
|
||||
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
|
||||
}
|
||||
|
||||
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
|
||||
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await self.transport.requestHealth(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
|
||||
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.transport.events()
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
try await self.transport.setActiveSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
try await self.transport.resetSession(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
try await self.transport.compactSession(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private actor LocalFixtureChatStore {
|
||||
private let fixture: LocalChatFixture
|
||||
private var messages: [OpenClawChatMessage]
|
||||
|
||||
init() {
|
||||
self.messages = AppleReviewDemoChatStore.seedMessages()
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.messages = Self.seedMessages(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
|
||||
try Self.decode(
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
|
||||
as: OpenClawChatCreateSessionResponse.self)
|
||||
}
|
||||
|
||||
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
|
||||
return try Self.decode(
|
||||
HistoryPayload(
|
||||
sessionKey: normalizedSessionKey,
|
||||
sessionId: "apple-review-demo-\(normalizedSessionKey)",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
|
||||
messages: self.messages,
|
||||
thinkingLevel: "auto"),
|
||||
as: OpenClawChatHistoryPayload.self)
|
||||
@@ -135,9 +312,8 @@ private actor AppleReviewDemoChatStore {
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
|
||||
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
|
||||
OpenClaw Gateway.
|
||||
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
|
||||
prepare agent actions, and keep the mobile workflow connected to the gateway.
|
||||
""",
|
||||
timestamp: now + 1))
|
||||
return try Self.decode(
|
||||
@@ -147,15 +323,15 @@ private actor AppleReviewDemoChatStore {
|
||||
|
||||
func sessions() throws -> OpenClawChatSessionsListResponse {
|
||||
let entry = OpenClawChatSessionEntry(
|
||||
key: self.sessionKey,
|
||||
key: self.fixture.sessionKey,
|
||||
kind: "chat",
|
||||
displayName: "Apple Review Demo",
|
||||
displayName: self.fixture.displayName,
|
||||
surface: "ios",
|
||||
subject: "Gateway review flow",
|
||||
subject: self.fixture.subject,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
sessionId: "apple-review-demo-main",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
|
||||
systemSent: true,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: "auto",
|
||||
@@ -163,52 +339,51 @@ private actor AppleReviewDemoChatStore {
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto")
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto",
|
||||
mainSessionKey: self.sessionKey),
|
||||
mainSessionKey: self.fixture.sessionKey),
|
||||
sessions: [entry])
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.messages = Self.seedMessages()
|
||||
self.messages = Self.seedMessages(fixture: self.fixture)
|
||||
}
|
||||
|
||||
private static func seedMessages() -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return [
|
||||
self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
timestamp: now),
|
||||
private static var thinkingOptions: [String] {
|
||||
["auto", "low", "medium", "high"]
|
||||
}
|
||||
|
||||
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
|
||||
[
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return fixture.seedMessages.enumerated().map { index, text in
|
||||
self.message(role: "assistant", text: text, timestamp: now + Double(index))
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: role,
|
||||
@@ -223,9 +398,9 @@ private actor AppleReviewDemoChatStore {
|
||||
timestamp: timestamp)
|
||||
}
|
||||
|
||||
private static func normalizedSessionKey(_ value: String) -> String {
|
||||
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
|
||||
|
||||
@@ -5,7 +5,7 @@ import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
|
||||
@@ -202,10 +202,4 @@ final class ContactsService: ContactsServicing {
|
||||
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
|
||||
@@ -303,7 +303,7 @@ extension AgentProTab {
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
@@ -557,7 +557,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
!self.appModel.isLocalGatewayFixtureEnabled &&
|
||||
self.gatewayConnected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
@@ -127,13 +125,11 @@ struct AgentProTab: View {
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
@@ -184,9 +180,6 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
@@ -195,11 +188,4 @@ struct AgentProTab: View {
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
@State private var viewModelTransportModeID = ""
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
@@ -64,6 +64,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaPadding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.navigationBarHidden(true)
|
||||
@@ -78,6 +79,10 @@ struct ChatProTab: View {
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
@@ -103,7 +108,6 @@ struct ChatProTab: View {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@@ -135,14 +139,12 @@ struct ChatProTab: View {
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
let transportModeID = self.appModel.chatTransportModeID
|
||||
guard let viewModel else {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -151,13 +153,11 @@ struct ChatProTab: View {
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
if self.viewModelTransportModeID != transportModeID {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -226,7 +226,7 @@ struct ChatProTab: View {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
|
||||
@@ -185,33 +185,3 @@ struct CommandEmptyStateRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandTaskRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Text(self.item.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
|
||||
Text(self.item.detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(width: 64, alignment: .leading)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 56)
|
||||
}
|
||||
Text(self.item.state)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +370,11 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
@@ -414,9 +413,7 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
@@ -765,9 +762,7 @@ struct CommandSessionsScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
@@ -779,11 +774,10 @@ struct CommandSessionsScreen: View {
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
if self.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.chatTransportModeID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,12 +180,11 @@ struct IPadActivityScreen: View {
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
@@ -215,9 +214,7 @@ struct IPadActivityScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
|
||||
@@ -213,32 +213,6 @@ struct IPadSkillWorkshopScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Status")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
ForEach(Self.proposalStatusFilters, id: \.self) { filter in
|
||||
Button(Self.proposalStatusFilterLabel(filter)) {
|
||||
self.statusFilter = filter
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.statusFilterLabel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(self.neutralControlTint)
|
||||
}
|
||||
}
|
||||
|
||||
private var agentScopeMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Agent")
|
||||
@@ -1130,7 +1104,6 @@ struct IPadSkillProposalRecord: Decodable {
|
||||
let description: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let proposedVersion: String
|
||||
let target: IPadSkillProposalTarget
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,6 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
|
||||
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
|
||||
static let lightCanvasBottom = Color.white
|
||||
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
|
||||
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
|
||||
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
|
||||
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
@@ -81,11 +74,6 @@ enum OpenClawBrand {
|
||||
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
|
||||
: UIColor.white
|
||||
})
|
||||
static let graphiteSoft = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
|
||||
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
|
||||
})
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
@@ -97,40 +85,6 @@ enum OpenClawBrand {
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static var toolbarChrome: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphiteElevated.opacity(0.92),
|
||||
graphite.opacity(0.78),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static func glassFill(brighten: Bool) -> Color {
|
||||
Color.black.opacity(brighten ? 0.10 : 0.22)
|
||||
}
|
||||
|
||||
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
|
||||
if active {
|
||||
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
|
||||
}
|
||||
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
|
||||
}
|
||||
|
||||
static func formSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
|
||||
colorScheme == .dark
|
||||
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
|
||||
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -5,7 +5,6 @@ enum OpenClawProMetric {
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@@ -250,13 +249,6 @@ struct OpenClawSidebarRevealButton: View {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
@@ -430,46 +422,6 @@ struct ProProgressBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProWorkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
var progress: Double?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Text(self.trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if let progress {
|
||||
ProProgressBar(progress: progress, color: self.color)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Text(self.state)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCapsule: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -553,94 +505,6 @@ struct OpenClawGatewayCompactPill: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
|
||||
Button {
|
||||
self.selection = index
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.trackFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func segmentFill(isSelected: Bool) -> Color {
|
||||
guard isSelected else { return .clear }
|
||||
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
|
||||
}
|
||||
|
||||
private var trackStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProHeroActionButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
|
||||
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
|
||||
radius: 18,
|
||||
isProminent: true,
|
||||
interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -795,24 +659,3 @@ struct ProStatusRow: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProTimelineRow: View {
|
||||
let done: Bool
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(
|
||||
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
|
||||
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
@@ -332,65 +332,6 @@ struct SettingsChannelsDestination: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
|
||||
@@ -139,15 +139,6 @@ extension SettingsProTab {
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
func refreshGateway() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
@@ -200,7 +191,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
@@ -298,7 +289,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@@ -327,7 +318,7 @@ extension SettingsProTab {
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
func preflightGateway(host: String, port: Int) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
|
||||
let queue = DispatchQueue(label: "ai.openclawfoundation.app.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
|
||||
@@ -3,7 +3,6 @@ import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import EventKit
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -126,6 +125,7 @@ final class GatewayConnectionController {
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
@@ -138,6 +138,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
@@ -147,7 +148,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if startDiscovery {
|
||||
if self.discoveryEnabled {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -157,6 +158,11 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
self.discovery.stop()
|
||||
@@ -169,12 +175,13 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func allowAutoConnectAgain() {
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
@@ -522,8 +529,7 @@ final class GatewayConnectionController {
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
|
||||
tlsEnabled: resolvedUseTLS)
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
@@ -719,8 +725,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
@@ -747,8 +752,7 @@ final class GatewayConnectionController {
|
||||
|
||||
private func resolveManualTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||
tlsEnabled: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
@@ -785,126 +789,6 @@ final class GatewayConnectionController {
|
||||
resolver.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
(host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBonjourServiceToHostPort(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||
{
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
// resume the continuation exactly once (timeout/cancel safe).
|
||||
@MainActor
|
||||
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||
private let service: NetService
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
private var finished = false
|
||||
|
||||
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||
self.cont = cont
|
||||
self.service = service
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(timeoutSeconds: TimeInterval) {
|
||||
self.service.delegate = self
|
||||
self.service.schedule(in: .main, forMode: .default)
|
||||
|
||||
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||
// callbacks never arrive (e.g. local network permission issues).
|
||||
self.timeoutTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
self.service.resolve(withTimeout: timeoutSeconds)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
self.finish(Self.extractHostPort(sender))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
private func finish(_ result: (host: String, port: Int)?) {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .default)
|
||||
|
||||
let c = self.cont
|
||||
self.cont = nil
|
||||
c?.resume(returning: result)
|
||||
}
|
||||
|
||||
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||
let port = svc.port
|
||||
|
||||
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
let rc = getnameinfo(
|
||||
base.assumingMemoryBound(to: sockaddr.self),
|
||||
socklen_t(ptr.count),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
let service = NetService(domain: domain, type: type, name: name)
|
||||
let resolver = Resolver(cont: cont, service: service)
|
||||
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
@@ -1162,30 +1046,10 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() async -> [String: Bool] {
|
||||
await self.currentPermissions()
|
||||
}
|
||||
|
||||
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
DeviceInfoHelper.platformString()
|
||||
}
|
||||
|
||||
func _test_deviceFamily() -> String {
|
||||
DeviceInfoHelper.deviceFamily()
|
||||
}
|
||||
|
||||
func _test_modelIdentifier() -> String {
|
||||
DeviceInfoHelper.modelIdentifier()
|
||||
}
|
||||
|
||||
func _test_appVersion() -> String {
|
||||
DeviceInfoHelper.appVersion()
|
||||
}
|
||||
|
||||
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
self.gateways = gateways
|
||||
}
|
||||
@@ -1199,10 +1063,9 @@ extension GatewayConnectionController {
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
}
|
||||
|
||||
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
|
||||
|
||||
@@ -59,7 +59,7 @@ final class GatewayDiscoveryModel {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
|
||||
@@ -2,18 +2,13 @@ import Foundation
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
private static let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private static let nodeService = "ai.openclawfoundation.app.node"
|
||||
private static let talkService = "ai.openclawfoundation.app.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
|
||||
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
|
||||
private static let manualHostDefaultsKey = "gateway.manual.host"
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
@@ -184,24 +179,6 @@ enum GatewaySettingsStore {
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
stableID
|
||||
case let .discovered(stableID, _):
|
||||
stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String, Codable {
|
||||
@@ -229,17 +206,6 @@ enum GatewaySettingsStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
|
||||
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
|
||||
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
||||
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(service: self.talkService, account: account)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
|
||||
@@ -477,8 +443,8 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
@@ -580,11 +546,4 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
self.queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID).bgrefresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.openclaw.ios</string>
|
||||
<string>ai.openclawfoundation.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>openclaw</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "LiveActivity")
|
||||
private let connectingStaleSeconds: TimeInterval = 120
|
||||
private let hydrationStaleSeconds: TimeInterval = 300
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
@@ -17,15 +17,6 @@ final class LiveActivityManager {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
guard let activity = self.currentActivity else { return false }
|
||||
guard activity.activityState == .active else {
|
||||
self.currentActivity = nil
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
@@ -96,10 +87,6 @@ final class LiveActivityManager {
|
||||
self.endActivity(reason: "connected")
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.endActivity(reason: "disconnected")
|
||||
}
|
||||
|
||||
func endActivity(reason: String) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
self.currentActivity = nil
|
||||
@@ -183,15 +170,6 @@ final class LiveActivityManager {
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
|
||||
@@ -14,39 +14,3 @@ struct OpenClawActivityAttributes: ActivityAttributes {
|
||||
var startedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension OpenClawActivityAttributes {
|
||||
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
|
||||
}
|
||||
|
||||
extension OpenClawActivityAttributes.ContentState {
|
||||
static let connecting = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: .now)
|
||||
|
||||
static let idle = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let disconnected = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let attention = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Approval needed",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,8 +12,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
|
||||
private var isStreaming = false
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
@@ -84,42 +82,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
self.isStreaming = true
|
||||
if significantChangesOnly {
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
} else {
|
||||
self.manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
|
||||
self.updatesContinuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { @MainActor in
|
||||
self.stopLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLocationUpdates() {
|
||||
guard self.isStreaming else { return }
|
||||
self.isStreaming = false
|
||||
self.manager.stopUpdatingLocation()
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
self.updatesContinuation?.finish()
|
||||
self.updatesContinuation = nil
|
||||
}
|
||||
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
|
||||
self.significantLocationCallback = onUpdate
|
||||
guard !self.isMonitoringSignificantChanges else { return }
|
||||
@@ -127,13 +89,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
func stopMonitoringSignificantLocationChanges() {
|
||||
guard self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = false
|
||||
self.significantLocationCallback = nil
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
@@ -161,9 +116,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
if let callback = self.significantLocationCallback, let latest = locs.last {
|
||||
callback(latest)
|
||||
}
|
||||
if let latest = locs.last, let updates = self.updatesContinuation {
|
||||
updates.yield(latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,14 +88,14 @@ final class NodeAppModel {
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
subsystem: "ai.openclawfoundation.app",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
@@ -112,6 +112,7 @@ final class NodeAppModel {
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
||||
private(set) var isScreenshotFixtureModeEnabled: Bool = false
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
@@ -133,7 +134,6 @@ final class NodeAppModel {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
private var focusedChatSessionKey: String?
|
||||
var selectedAgentId: String?
|
||||
@@ -188,6 +188,7 @@ final class NodeAppModel {
|
||||
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
|
||||
private var backgroundReconnectSuppressed = false
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
|
||||
@@ -201,19 +202,47 @@ final class NodeAppModel {
|
||||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession {
|
||||
self.nodeGateway
|
||||
}
|
||||
|
||||
var operatorSession: GatewayNodeSession {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
var localChatFixture: LocalChatFixture? {
|
||||
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
|
||||
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isLocalChatFixtureEnabled: Bool {
|
||||
self.localChatFixture != nil
|
||||
}
|
||||
|
||||
var isLocalGatewayFixtureEnabled: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
|
||||
}
|
||||
|
||||
var chatTransportModeID: String {
|
||||
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
|
||||
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
func makeChatTransport() -> any OpenClawChatTransport {
|
||||
if self.isScreenshotFixtureModeEnabled {
|
||||
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
||||
}
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
return AppleReviewDemoChatTransport()
|
||||
}
|
||||
return IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
|
||||
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
|
||||
private static let foregroundResumeHealthTimeoutSeconds = 1
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
@@ -417,9 +446,7 @@ final class NodeAppModel {
|
||||
self.isBackgrounded = false
|
||||
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
|
||||
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
|
||||
if self.operatorConnected {
|
||||
self.startGatewayHealthMonitor()
|
||||
}
|
||||
var shouldStartGatewayHealthMonitor = self.operatorConnected
|
||||
if phase == .active {
|
||||
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
|
||||
self.backgroundVoiceWakeSuspended = false
|
||||
@@ -444,6 +471,8 @@ final class NodeAppModel {
|
||||
// iOS may suspend network sockets in background without a clean close.
|
||||
// On foreground, force a fresh handshake to avoid "connected but dead" states.
|
||||
if backgroundedFor >= 3.0 {
|
||||
shouldStartGatewayHealthMonitor = false
|
||||
self.foregroundGatewayResumeCheckInFlight = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
||||
@@ -452,31 +481,26 @@ final class NodeAppModel {
|
||||
let healthy = await (try? self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 2)) != nil
|
||||
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
|
||||
if healthy {
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
await MainActor.run {
|
||||
self.foregroundGatewayResumeCheckInFlight = false
|
||||
self.startGatewayHealthMonitor()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
// Foreground recovery must actively restart the saved gateway config.
|
||||
// Disconnecting stale sockets alone can leave us idle if the old
|
||||
// reconnect tasks were suppressed or otherwise got stuck in background.
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
if let cfg = self.activeGatewayConnectConfig {
|
||||
self.applyGatewayConnectConfig(cfg)
|
||||
}
|
||||
self.foregroundGatewayResumeCheckInFlight = false
|
||||
}
|
||||
await self.restartGatewaySessionsAfterForegroundStaleConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldStartGatewayHealthMonitor {
|
||||
self.startGatewayHealthMonitor()
|
||||
}
|
||||
@unknown default:
|
||||
self.isBackgrounded = false
|
||||
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
|
||||
@@ -554,7 +578,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -727,11 +751,6 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
|
||||
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||
@@ -741,12 +760,9 @@ final class NodeAppModel {
|
||||
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let session = config["session"] as? [String: Any]
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
@@ -786,6 +802,12 @@ final class NodeAppModel {
|
||||
|
||||
func refreshGatewayOverviewIfConnected() async {
|
||||
guard await self.isOperatorConnected() else { return }
|
||||
if self.foregroundGatewayResumeCheckInFlight {
|
||||
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
|
||||
guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
|
||||
}
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
}
|
||||
@@ -923,7 +945,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -1945,7 +1967,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
self.activeGatewayConnectConfig = nextConfig
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
self.prepareForGatewayConnect(stableID: effectiveStableID)
|
||||
if operatorLoopRequired {
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
@@ -1972,6 +1994,7 @@ extension NodeAppModel {
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -1986,16 +2009,38 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func resetGatewaySessionsForForcedReconnect() async {
|
||||
self.nodeGatewayTask?.cancel()
|
||||
let nodeGatewayTask = self.nodeGatewayTask
|
||||
let operatorGatewayTask = self.operatorGatewayTask
|
||||
nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
// Foreground recovery reuses the same config immediately after reset.
|
||||
// Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
|
||||
if let operatorGatewayTask {
|
||||
await operatorGatewayTask.value
|
||||
}
|
||||
if let nodeGatewayTask {
|
||||
await nodeGatewayTask.value
|
||||
}
|
||||
}
|
||||
|
||||
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.resetGatewaySessionsForForcedReconnect()
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
guard let cfg = self.activeGatewayConnectConfig else { return }
|
||||
self.applyGatewayConnectConfig(cfg, forceReconnect: true)
|
||||
}
|
||||
|
||||
func disconnectGateway() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2021,7 +2066,6 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
ShareGatewayRelaySettings.clearConfig()
|
||||
@@ -2030,8 +2074,9 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
private func prepareForGatewayConnect(stableID: String) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2074,7 +2119,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -2100,7 +2145,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.operatorGatewayProblem = problem
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
@@ -2333,7 +2378,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.setOperatorConnected(true)
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
@@ -2355,7 +2400,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
@@ -2380,7 +2425,7 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
||||
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
||||
@@ -2446,7 +2491,7 @@ extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
@@ -2474,7 +2519,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
@@ -2533,7 +2578,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
@@ -2583,7 +2628,7 @@ extension NodeAppModel {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
@@ -2624,7 +2669,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
@@ -2634,7 +2679,6 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
@@ -2775,6 +2819,7 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2807,7 +2852,6 @@ extension NodeAppModel {
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
@@ -2816,6 +2860,47 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
||||
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
||||
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.talkMode.enterScreenshotFixtureMode()
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -2922,14 +3007,6 @@ extension NodeAppModel {
|
||||
self.refreshLastShareEventFromRelay()
|
||||
}
|
||||
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
@@ -3890,32 +3967,6 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: decision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
@@ -4452,17 +4503,6 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
func approvePendingAgentDeepLinkPrompt() async {
|
||||
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
|
||||
self.pendingAgentDeepLinkPrompt = nil
|
||||
@@ -4612,30 +4652,10 @@ extension NodeAppModel {
|
||||
try self.encodePayload(obj)
|
||||
}
|
||||
|
||||
func _test_isCameraEnabled() -> Bool {
|
||||
self.isCameraEnabled()
|
||||
}
|
||||
|
||||
func _test_triggerCameraFlash() {
|
||||
self.triggerCameraFlash()
|
||||
}
|
||||
|
||||
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
||||
}
|
||||
|
||||
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
||||
await self.handleCanvasA2UIAction(body: body)
|
||||
}
|
||||
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
@@ -4826,6 +4846,10 @@ extension NodeAppModel {
|
||||
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
|
||||
}
|
||||
|
||||
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.restartGatewaySessionsAfterForegroundStaleConnection()
|
||||
}
|
||||
|
||||
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Connect to your gateway to get started.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Auto detect") {
|
||||
AutoDetectStep()
|
||||
}
|
||||
NavigationLink("Manual entry") {
|
||||
ManualEntryStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoDetectStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text("We’ll scan for gateways on your network and connect automatically when we find one.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auto detect")
|
||||
.onAppear { self.triggerAutoConnect() }
|
||||
.onChange(of: self.gatewayController.gateways) { _, _ in
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAutoConnect() {
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
guard let candidate = self.autoCandidate() else { return }
|
||||
|
||||
self.connectingGatewayID = candidate.id
|
||||
Task {
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !preferred.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if !lastDiscovered.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if self.gatewayController.gateways.count == 1 {
|
||||
return self.gatewayController.gateways.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPortText: String = ""
|
||||
@State private var manualUseTLS: Bool = true
|
||||
@State private var manualToken: String = ""
|
||||
@State private var manualPassword: String = ""
|
||||
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Setup code") {
|
||||
Text("Use /pair in your bot to get a setup code.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button("Apply setup code") {
|
||||
self.applySetupCode()
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let setupStatusText, !setupStatusText.isEmpty {
|
||||
Text(setupStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualUseTLS)
|
||||
|
||||
TextField("Gateway token", text: self.$manualToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway password", text: self.$manualPassword)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Manual entry")
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
|
||||
if let port = self.manualPortValue(), !(1...65535).contains(port) {
|
||||
self.connectStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.manual.enabled")
|
||||
defaults.set(host, forKey: "gateway.manual.host")
|
||||
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
|
||||
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
|
||||
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty {
|
||||
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedToken.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "manual"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.manualToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.manualPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualPortValue() ?? 0,
|
||||
useTLS: self.manualUseTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.manualHost = ""
|
||||
self.manualPortText = ""
|
||||
self.manualUseTLS = true
|
||||
self.manualToken = ""
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return
|
||||
}
|
||||
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return
|
||||
}
|
||||
|
||||
self.manualHost = link.host
|
||||
self.manualPortText = String(link.port)
|
||||
self.manualUseTLS = link.tls
|
||||
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.manualToken = setupAuth.token
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.manualPassword = setupAuth.password
|
||||
}
|
||||
|
||||
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(
|
||||
appModel: self.appModel,
|
||||
instanceId: trimmedInstanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
|
||||
self.setupStatusText = "Setup code applied."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
connectingGatewayID: inout String?)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
connectStatusText = nil
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
secondaryLine: String?) -> some View
|
||||
{
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: gatewayConnectionStatusLines(
|
||||
appModel: appModel,
|
||||
gatewayController: gatewayController),
|
||||
secondaryLine: secondaryLine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.statusLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let secondaryLine, !secondaryLine.isEmpty {
|
||||
Text(secondaryLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")")
|
||||
return lines
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,6 @@ enum OnboardingStateStore {
|
||||
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
|
||||
@@ -17,10 +17,6 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
Self(rawValue: self.rawValue - 1)
|
||||
}
|
||||
|
||||
var next: Self? {
|
||||
Self(rawValue: self.rawValue + 1)
|
||||
}
|
||||
|
||||
/// Progress label for the manual setup flow (mode → connect → auth → success).
|
||||
var manualProgressTitle: String {
|
||||
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
@@ -22,9 +22,22 @@ enum OpenClawAppModelRegistry {
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
|
||||
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "BackgroundWake")
|
||||
private static var wakeRefreshTaskIdentifier: String {
|
||||
"\(appBundleIdentifier).bgrefresh"
|
||||
}
|
||||
|
||||
private static var appBundleIdentifier: String {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleId.isEmpty
|
||||
else {
|
||||
return "ai.openclawfoundation.app"
|
||||
}
|
||||
|
||||
return bundleId
|
||||
}
|
||||
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
@@ -92,6 +105,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
|
||||
func _test_wakeRefreshTaskIdentifier() -> String {
|
||||
Self.wakeRefreshTaskIdentifier
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
@@ -611,9 +628,21 @@ struct OpenClawApp: App {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
#if DEBUG
|
||||
if Self.screenshotModeEnabled {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
UserDefaults.standard.set(true, forKey: "gateway.onboardingComplete")
|
||||
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
|
||||
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
|
||||
appModel.enterScreenshotFixtureMode()
|
||||
}
|
||||
#endif
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -649,6 +678,14 @@ struct OpenClawApp: App {
|
||||
?? .system
|
||||
}
|
||||
|
||||
private static var screenshotModeEnabled: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-mode")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
|
||||
@@ -39,10 +39,6 @@ struct PushBuildConfig {
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
var usesRelay: Bool {
|
||||
self.transport == .relay
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -13,7 +13,7 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclaw.pushrelay"
|
||||
private static let service = "ai.openclawfoundation.app.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
@@ -71,11 +71,6 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearRegistrationState() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -7,7 +7,6 @@ extension RootTabs {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
|
||||
}
|
||||
}
|
||||
@@ -181,16 +181,6 @@ final class ScreenController {
|
||||
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
func snapshotBase64(
|
||||
maxWidth: CGFloat? = nil,
|
||||
format: OpenClawCanvasSnapshotFormat,
|
||||
|
||||
@@ -56,7 +56,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "ai.openclawfoundation.app.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -31,12 +31,7 @@ protocol LocationServicing: Sendable {
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
func stopLocationUpdates()
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
|
||||
func stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -26,7 +26,7 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
|
||||
@@ -22,11 +22,4 @@ enum SessionKey {
|
||||
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return agentId.isEmpty ? nil : agentId
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed == "global" { return true }
|
||||
return trimmed.hasPrefix("agent:")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,13 +121,18 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.contactsStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let granted = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
await MainActor.run { self.refreshAll() }
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.contactsStatus = .authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -164,8 +169,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .writeOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -206,8 +216,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestCalendarFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestCalendarFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -248,8 +263,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestRemindersFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestRemindersFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.remindersStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SettingsHostPort: Equatable {
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
|
||||
enum SettingsNetworkingHelpers {
|
||||
static func parseHostPort(from address: String) -> SettingsHostPort? {
|
||||
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("["),
|
||||
let close = trimmed.firstIndex(of: "]"),
|
||||
close < trimmed.endIndex
|
||||
{
|
||||
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
|
||||
let portStart = trimmed.index(after: close)
|
||||
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
|
||||
let portString = String(trimmed[trimmed.index(after: portStart)...])
|
||||
guard let port = Int(portString) else { return nil }
|
||||
return SettingsHostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
|
||||
let host = String(trimmed[..<colon])
|
||||
let portString = String(trimmed[trimmed.index(after: colon)...])
|
||||
guard !host.isEmpty, let port = Int(portString) else { return nil }
|
||||
return SettingsHostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||
if let host, let port {
|
||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
|
||||
let hostPart = needsBrackets ? "[\(host)]" : host
|
||||
return "http://\(hostPart):\(port)"
|
||||
}
|
||||
return "http://\(fallback)"
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ final class RealtimeTalkRelaySession {
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
private let pcmPlayer: PCMStreamingAudioPlaying
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "RealtimeTalkRelay")
|
||||
private let onStatus: (String) -> Void
|
||||
private let onIssue: (TalkRuntimeIssue) -> Void
|
||||
private let onSpeakingChanged: (Bool) -> Void
|
||||
|
||||
@@ -3,7 +3,6 @@ import OpenClawKit
|
||||
|
||||
enum TalkModeExecutionMode {
|
||||
case native
|
||||
case realtimeClient
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
|
||||
@@ -64,22 +64,6 @@ extension TalkModeManager {
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
|
||||
@@ -70,11 +70,6 @@ final class TalkModeManager: NSObject {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
var hasActiveAudioCapture: Bool {
|
||||
self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil
|
||||
|| self.realtimeRelayStartInFlight
|
||||
}
|
||||
|
||||
private enum CaptureMode {
|
||||
case idle
|
||||
case continuous
|
||||
@@ -175,7 +170,7 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
|
||||
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkMode")
|
||||
|
||||
private static func nowSeconds() -> TimeInterval {
|
||||
ProcessInfo.processInfo.systemUptime
|
||||
@@ -225,6 +220,34 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.updateGatewayConnected(true)
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
self.statusText = "Ready"
|
||||
self.gatewayTalkConfigLoaded = true
|
||||
self.gatewayTalkApiKeyConfigured = true
|
||||
self.gatewayTalkDefaultModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkDefaultVoiceId = "marin"
|
||||
self.gatewayTalkProviderLabel = "OpenAI"
|
||||
self.gatewayTalkTransportLabel = "Gateway Relay"
|
||||
self.gatewayTalkUsesRealtime = true
|
||||
self.gatewayTalkUsesRealtimeRelay = true
|
||||
self.gatewayTalkRealtimeProviderLabel = "OpenAI"
|
||||
self.gatewayTalkRealtimeModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkRealtimeVoiceId = "marin"
|
||||
self.gatewayTalkVoiceModeTitle = "Realtime Voice"
|
||||
self.gatewayTalkVoiceModeSubtitle = "Gateway relay ready"
|
||||
self.gatewayTalkVoiceModeAccessibilityValue = "Realtime Voice, Gateway relay ready"
|
||||
self.gatewayTalkActiveModeTitle = "Ready"
|
||||
self.gatewayTalkActiveModeSubtitle = "Listening starts from this phone"
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkPermissionState = .ready
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
@@ -475,13 +498,6 @@ final class TalkModeManager: NSObject {
|
||||
return wasActive
|
||||
}
|
||||
|
||||
func setForegroundAudioCaptureAllowed(_ allowed: Bool) {
|
||||
self.foregroundAudioCaptureAllowed = allowed
|
||||
if !allowed {
|
||||
self.cancelPendingStart()
|
||||
}
|
||||
}
|
||||
|
||||
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
|
||||
if wasKeptActive { return }
|
||||
guard wasSuspended else { return }
|
||||
@@ -489,14 +505,6 @@ final class TalkModeManager: NSObject {
|
||||
await self.start()
|
||||
}
|
||||
|
||||
func userTappedOrb() {
|
||||
if let realtimeSession {
|
||||
realtimeSession.cancelResponse()
|
||||
}
|
||||
self.realtimeRelaySession?.cancelOutput()
|
||||
self.stopSpeaking()
|
||||
}
|
||||
|
||||
func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload {
|
||||
guard self.gatewayConnected else {
|
||||
self.statusText = "Offline"
|
||||
@@ -3104,23 +3112,6 @@ extension TalkModeManager {
|
||||
self.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
func _test_seedTranscript(_ transcript: String) {
|
||||
self.lastTranscript = transcript
|
||||
self.lastHeard = Date()
|
||||
}
|
||||
|
||||
func _test_handleTranscript(_ transcript: String, isFinal: Bool) async {
|
||||
await self.handleTranscript(transcript: transcript, isFinal: isFinal)
|
||||
}
|
||||
|
||||
func _test_backdateLastHeard(seconds: TimeInterval) {
|
||||
self.lastHeard = Date().addingTimeInterval(-seconds)
|
||||
}
|
||||
|
||||
func _test_runSilenceCheck() async {
|
||||
await self.checkSilence()
|
||||
}
|
||||
|
||||
func _test_incrementalReset() {
|
||||
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct TalkPermissionPromptView: View {
|
||||
enum Style {
|
||||
case card
|
||||
case settings
|
||||
case sheet
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
|
||||
|
||||
@MainActor
|
||||
final class TalkRealtimeWebRTCSession: NSObject {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "TalkRealtimeWebRTC")
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkRealtimeWebRTC")
|
||||
private static let consultToolName = "openclaw_agent_consult"
|
||||
private static let controlToolName = "openclaw_agent_control"
|
||||
private static let defaultOfferURL = "https://api.openai.com/v1/realtime/calls"
|
||||
@@ -61,7 +61,6 @@ final class TalkRealtimeWebRTCSession: NSObject {
|
||||
let runId: String?
|
||||
let status: String?
|
||||
let startedAt: Double?
|
||||
let endedAt: Double?
|
||||
let error: String?
|
||||
let stopReason: String?
|
||||
let timeoutPhase: String?
|
||||
@@ -196,11 +195,6 @@ final class TalkRealtimeWebRTCSession: NSObject {
|
||||
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func cancelResponse() {
|
||||
self.sendRealtimeEvent(["type": "response.cancel"])
|
||||
self.cancelActiveToolCalls()
|
||||
}
|
||||
|
||||
private func cancelActiveToolCalls() {
|
||||
let runIds = Array(Set(activeToolRunIds.values))
|
||||
for task in self.activeToolTasks.values {
|
||||
|
||||
@@ -70,14 +70,6 @@ enum TalkSpeechLocale {
|
||||
return (recognizer, recognizer?.locale.identifier)
|
||||
}
|
||||
|
||||
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
|
||||
}
|
||||
|
||||
private static func normalizedLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedSpeechLocaleID(raw)
|
||||
}
|
||||
|
||||
private static func canonicalID(_ raw: String) -> String {
|
||||
raw.replacingOccurrences(of: "_", with: "-")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ Sources/Design/ChatProTab.swift
|
||||
Sources/Design/CommandCenterTab.swift
|
||||
Sources/Design/TalkProTab.swift
|
||||
Sources/Design/OpenClawProComponents.swift
|
||||
Sources/Design/OpenClawProScreens.swift
|
||||
Sources/Design/SettingsProTab.swift
|
||||
Sources/Design/SettingsProTabSupport.swift
|
||||
Sources/Design/SettingsProTabSections.swift
|
||||
@@ -67,7 +66,6 @@ Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/WatchReplyCoordinator.swift
|
||||
Sources/Motion/MotionService.swift
|
||||
Sources/Onboarding/GatewayOnboardingReset.swift
|
||||
Sources/Onboarding/GatewayOnboardingView.swift
|
||||
Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardSteps.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
@@ -83,7 +81,6 @@ Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootTabsNavigation.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
@@ -94,7 +91,6 @@ Sources/Services/WatchMessagingPayloadCodec.swift
|
||||
Sources/Services/WatchMessagingService.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
|
||||
@@ -356,16 +356,33 @@ import UIKit
|
||||
#expect(!appModel._test_hasGatewayLoopTasks().operator)
|
||||
}
|
||||
|
||||
@Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async {
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
|
||||
let config = Self.makeGatewayConnectConfig()
|
||||
appModel.applyGatewayConnectConfig(config)
|
||||
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
|
||||
|
||||
#expect(appModel.gatewayStatusText == "Reconnecting…")
|
||||
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
|
||||
#expect(appModel._test_hasGatewayLoopTasks().node)
|
||||
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
@@ -381,15 +398,18 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
|
||||
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
|
||||
withUserDefaults([
|
||||
|
||||
@@ -39,19 +39,19 @@ import Testing
|
||||
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
@@ -59,17 +59,17 @@ import Testing
|
||||
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ import Testing
|
||||
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.autoconnect")
|
||||
@@ -90,13 +90,13 @@ import Testing
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
fingerprint: nil)
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
@@ -104,7 +104,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
|
||||
@@ -120,7 +120,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
|
||||
@@ -131,7 +131,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
|
||||
let controller = makeController()
|
||||
let controller = self.makeController()
|
||||
|
||||
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
|
||||
|
||||
@@ -7,13 +7,11 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let talkService = "ai.openclaw.talk"
|
||||
private let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private let nodeService = "ai.openclawfoundation.app.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
|
||||
private let bootstrapDefaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
@@ -187,17 +185,4 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_genericRoundTrip() {
|
||||
let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry])
|
||||
defer { restoreKeychain(keychainSnapshot) }
|
||||
|
||||
_ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account)
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key")
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
|
||||
@Suite struct KeychainStoreTests {
|
||||
@Test func saveLoadUpdateDeleteRoundTrip() {
|
||||
let service = "ai.openclaw.tests.\(UUID().uuidString)"
|
||||
let service = "ai.openclawfoundation.app.tests.\(UUID().uuidString)"
|
||||
let account = "value"
|
||||
|
||||
#expect(KeychainStore.delete(service: service, account: account))
|
||||
|
||||
@@ -56,12 +56,6 @@ import Testing
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
|
||||
OnboardingStateStore.markIncomplete(defaults: defaults)
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
}
|
||||
|
||||
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OpenClawAppDelegateTests {
|
||||
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
|
||||
@Test @MainActor func `resolves registry model before view task assigns delegate model`() {
|
||||
let registryModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
@@ -12,7 +13,7 @@ import Testing
|
||||
#expect(delegate._test_resolvedAppModel() === registryModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
|
||||
@Test @MainActor func `prefers explicit delegate model over registry fallback`() {
|
||||
let registryModel = NodeAppModel()
|
||||
let explicitModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
@@ -23,4 +24,11 @@ import Testing
|
||||
|
||||
#expect(delegate._test_resolvedAppModel() === explicitModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func `derives background refresh task identifier from app bundle identifier`() {
|
||||
let delegate = OpenClawAppDelegate()
|
||||
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "ai.openclawfoundation.app.tests"
|
||||
|
||||
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +153,9 @@ import Testing
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
let directDestination = try Self.extract(
|
||||
source,
|
||||
from: "private func directDestination(for route: AgentRoute) -> some View",
|
||||
to: "private func applyInitialRouteIfNeeded()")
|
||||
|
||||
#expect(!directDestination.contains("ToolbarItem"))
|
||||
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(!source.contains("ToolbarItem"))
|
||||
#expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
@@ -498,7 +494,6 @@ import Testing
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
@@ -522,9 +517,7 @@ import Testing
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
|
||||
@@ -29,50 +29,4 @@ import Testing
|
||||
talkConfigLoaded: true,
|
||||
notificationStatusText: "Allowed") == 0)
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesIPv4() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesHostnameAndTrims() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
|
||||
host: "example.com",
|
||||
port: 80))
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesBracketedIPv6() {
|
||||
#expect(
|
||||
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
|
||||
.init(host: "2001:db8::1", port: 443))
|
||||
}
|
||||
|
||||
@Test func parseHostPortRejectsMissingPort() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
|
||||
}
|
||||
|
||||
@Test func parseHostPortRejectsInvalidPort() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
|
||||
}
|
||||
|
||||
@Test func httpURLStringFormatsIPv4AndPort() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringBracketsIPv6() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
|
||||
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
|
||||
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ import UIKit
|
||||
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(OpenClawDocsScreen()),
|
||||
AnyView(SettingsChannelsScreen()),
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
AnyView(AgentProTab(directRoute: .agents)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user