Compare commits

..

296 Commits

Author SHA1 Message Date
Alex Knight
7f74f595eb feat(plugin-sdk): expose tool execution context 2026-06-22 22:28:36 +10:00
Vincent Koc
66b94ba577 fix(process): clamp execfile timeouts 2026-06-22 01:10:52 +02:00
Vincent Koc
77b6ca9a9b fix(sdk): tighten surface report budgets 2026-06-22 01:04:53 +02:00
Vincent Koc
1425bb3a03 fix(process): clamp command timeouts 2026-06-22 01:00:41 +02:00
Peter Steinberger
11484f8a14 fix(ollama): support GLM-5.2 cloud discovery 2026-06-21 19:00:15 -04:00
mikasa
ec7a548062 fix #95378: https://github.com/openclaw/openclaw/issues/95378 (#95390)
* fix(telegram): use session transcript for direct context

* fix(telegram): account for proof and SDK checks

* fix(telegram): address review findings

* fix(telegram): tighten session transcript context

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-22 04:29:44 +05:30
Vincent Koc
8ecdb97b63 fix(channels): bound capabilities probes 2026-06-22 00:53:26 +02:00
Vincent Koc
a39e548ede fix(mcp): cap channel bridge request limits 2026-06-22 00:32:32 +02:00
Vincent Koc
328a44695f chore(deadcode): remove unused agent-core prompt formatter 2026-06-22 06:23:07 +08:00
Vincent Koc
c21dcfc7c2 fix(tui): clamp local shutdown grace timeout 2026-06-22 00:22:08 +02:00
Vincent Koc
2bdcc8314d fix(sdk): preserve zero run timeout watchdog 2026-06-22 00:13:51 +02:00
Vincent Koc
464adfe5e5 chore(deadcode): remove unused agent-core harness APIs 2026-06-22 06:08:32 +08:00
Vincent Koc
b66b4504f8 fix(gateway): cap plugin session message reads 2026-06-22 00:06:04 +02:00
Vincent Koc
f6d432e545 fix(gateway): expire default node pending work 2026-06-21 23:52:44 +02:00
Vincent Koc
fd13192adc fix(qa-lab): version UI assets from repo root 2026-06-21 23:43:48 +02:00
Vincent Koc
1c63da09d8 fix(qa-lab): cap agent wait timeout payloads 2026-06-21 23:34:38 +02:00
Vincent Koc
735505442c chore(deadcode): dedupe live assistant text extraction 2026-06-22 05:32:29 +08:00
Vincent Koc
108d6d7eca fix(gateway): reject malformed restart request params 2026-06-21 23:28:53 +02:00
Vincent Koc
984c8f6ea0 chore(deadcode): remove stale channel presence helper 2026-06-22 05:21:39 +08:00
Shakker
cd9060e06a test: restore host cleanup state env 2026-06-21 22:20:58 +01:00
Vincent Koc
5b96eb0172 fix(e2e): reject flag Parallels smoke values 2026-06-21 23:19:32 +02:00
Vincent Koc
b95b725c83 fix(e2e): reject help flag Crabbox proof values 2026-06-21 23:13:12 +02:00
Shakker
4b881509eb fix: restore media cache state env 2026-06-21 22:12:46 +01:00
Vincent Koc
a03032a272 chore(plugin-sdk): refresh API baseline hash 2026-06-21 23:09:27 +02:00
Shakker
c94ebdbebd test: restore heartbeat state env 2026-06-21 22:07:04 +01:00
Alex Knight
7b46167607 fix(channels): preserve migrated account policies 2026-06-22 07:05:45 +10:00
Vincent Koc
0e53358945 fix(e2e): reject flag Telegram credential values 2026-06-21 23:04:51 +02:00
Vincent Koc
6b45e9af7a fix(scripts): validate iOS node CLI values before help 2026-06-21 23:00:30 +02:00
Vincent Koc
2ef61eb782 chore(deadcode): drop unused exact session lookup 2026-06-22 04:58:21 +08:00
Vincent Koc
6823f56d8e fix(scripts): reject flag device-pair Telegram values 2026-06-21 22:56:54 +02:00
Shakker
3e1d3c5feb fix: isolate provider runtime env cache 2026-06-21 21:54:05 +01:00
Vincent Koc
dfbc9ab246 fix(scripts): reject short flag TUI PTY values 2026-06-21 22:52:21 +02:00
Vincent Koc
179eb15554 chore(deadcode): remove unused state path readers 2026-06-22 04:50:54 +08:00
Shakker
c3b1e926e8 test: route gateway call env resets 2026-06-21 21:46:55 +01:00
Vincent Koc
89768d456b fix(scripts): reject short flag Discord smoke values 2026-06-21 22:45:03 +02:00
Vincent Koc
609d7a14b1 chore(deadcode): remove test-only helper APIs 2026-06-22 04:41:04 +08:00
Vincent Koc
3bae0d6b82 fix(qa): reject short flag gateway smoke values 2026-06-21 22:40:40 +02:00
Vincent Koc
75a997dd7c fix(scripts): reject short flag shrinkwrap refs 2026-06-21 22:40:07 +02:00
Shakker
ab8dc3af52 fix: reuse doctor env snapshot helper 2026-06-21 21:37:56 +01:00
Vincent Koc
bebc5d847d fix(scripts): reject short flag CI timing limits 2026-06-21 22:34:29 +02:00
Vincent Koc
a0f28bd3f5 fix(scripts): reject short flag version values 2026-06-21 22:33:59 +02:00
Shakker
c638617897 test: share agent state-dir env guard 2026-06-21 21:31:10 +01:00
Vincent Koc
b77d6149e1 fix(scripts): reject short flag extension memory values 2026-06-21 22:28:14 +02:00
Vincent Koc
36db108fc1 fix(scripts): reject short flag boundary values 2026-06-21 22:26:44 +02:00
heichaowo
e00c1eebc4 fix(telegram): skip duplicate mirror replies (#95069)
Fix Telegram session transcript duplicate mirror writes after the primary assistant reply already exists.

Thanks @heichaowo!
2026-06-22 01:55:08 +05:30
NIO
32d22d04cc fix(telegram): honor outbound reaction directives (#94977) 2026-06-22 01:54:55 +05:30
Vincent Koc
648ef73bde fix(qa): reject short flag UX evidence paths 2026-06-21 22:20:56 +02:00
Vincent Koc
beebb35de4 fix(scripts): reject short flag values in helper CLIs 2026-06-21 22:19:54 +02:00
Vincent Koc
55959148ca chore(deadcode): remove unused infra wrappers 2026-06-22 04:18:02 +08:00
Vincent Koc
4684bbba97 fix(scripts): reject short flag values in benchmark CLIs 2026-06-21 22:11:19 +02:00
Vincent Koc
409adfbe10 chore(deadcode): drop stale helper APIs 2026-06-22 04:10:02 +08:00
Vincent Koc
2609b97222 fix(scripts): reject short flag values in bench parsers 2026-06-21 22:06:30 +02:00
Vincent Koc
03bc600e67 fix(scripts): reject short flag startup memory paths 2026-06-21 22:06:04 +02:00
Vincent Koc
8102d5ebc3 fix(scripts): reject short flag performance summary paths 2026-06-21 22:02:05 +02:00
Vincent Koc
6399eb8191 fix(scripts): reject short flag vitest profile dirs 2026-06-21 21:53:58 +02:00
Vincent Koc
1a5839fbd8 fix(scripts): reject short flag audit severity values 2026-06-21 21:51:02 +02:00
Vincent Koc
d17045db6f chore(deadcode): drop unused helper exports 2026-06-22 03:49:17 +08:00
Vincent Koc
2a8db1fc23 fix(scripts): reject short flag ownership report values 2026-06-21 21:45:36 +02:00
Vincent Koc
82f43f0a62 fix(scripts): reject short flag values in release docs parsers 2026-06-21 21:39:41 +02:00
Vincent Koc
9adf3d92bd chore(deadcode): remove unused helper paths 2026-06-22 03:39:19 +08:00
Vincent Koc
e21164933a fix(scripts): reject short flag report values 2026-06-21 21:36:26 +02:00
Vincent Koc
1b17517969 fix(scripts): reject short flag closeout values 2026-06-21 21:32:17 +02:00
Vincent Koc
86fea26797 fix(plugin-sdk): stabilize surface report after builds 2026-06-21 21:28:47 +02:00
Vincent Koc
2e7c3ace9c chore(plugin-sdk): refresh API baseline hashes 2026-06-21 21:28:47 +02:00
Vincent Koc
e34204a1e0 fix(scripts): reject short flag package roots 2026-06-21 21:27:57 +02:00
Vincent Koc
6daf9307e0 fix(scripts): reject short flag docker package values 2026-06-21 21:24:30 +02:00
Vincent Koc
ebb670b208 fix(scripts): reject short flag gauntlet values 2026-06-21 21:18:48 +02:00
Vincent Koc
52672c7af1 fix(scripts): reject short flag gateway cpu values 2026-06-21 21:15:21 +02:00
Vincent Koc
690efd2a16 chore(deadcode): inline constant helper stubs 2026-06-22 03:14:04 +08:00
Vincent Koc
102ab759e7 fix(scripts): reject option-shaped package candidate values 2026-06-21 21:11:25 +02:00
Vincent Koc
7da955fae4 fix(config): type slack secret refs 2026-06-21 20:58:33 +02:00
Vincent Koc
06a0148072 chore(deadcode): remove inert browser relay hook 2026-06-22 02:50:55 +08:00
Vincent Koc
edd1d3319c chore(deadcode): dedupe repeated literal lists 2026-06-22 02:35:03 +08:00
Vincent Koc
0ea39a2276 chore(deadcode): remove inert memory provider bootstrap 2026-06-22 02:30:56 +08:00
Sarah Fortune
6fa944e80f [codex] Add Slack relay mode for incoming messages (#94707) 2026-06-21 11:28:33 -07:00
Vincent Koc
4c453c931f fix(test): reject dependency report flag values 2026-06-21 20:23:23 +02:00
Vincent Koc
13b0976c70 fix(release): reject dependency evidence flag values 2026-06-21 20:19:49 +02:00
Vincent Koc
43e00c06c3 fix(docs): reject sync publish flag values 2026-06-21 20:13:50 +02:00
Vincent Koc
03ce3d41b1 fix(ci): reject hosted gate flag values 2026-06-21 20:09:53 +02:00
Vincent Koc
bda05dbc2f fix(release): reject validation flag values 2026-06-21 20:05:22 +02:00
Vincent Koc
7069d95720 fix(test): reject diff ref flag values 2026-06-21 19:59:55 +02:00
Vincent Koc
8086cffd17 fix(test): reject group report flag values 2026-06-21 19:56:25 +02:00
Vincent Koc
d91aee7220 fix(ci): reject flag refs in changed scope 2026-06-21 19:54:57 +02:00
Vincent Koc
c8ab37f6fe chore(deadcode): drop inert legacy workspace doctor check 2026-06-22 01:47:27 +08:00
Vincent Koc
a823cb2b30 fix(test): bound rpc process tree sampling 2026-06-21 19:41:51 +02:00
Vincent Koc
5230ec66ae chore(plugin-sdk): refresh API surface baselines 2026-06-21 19:32:49 +02:00
Vincent Koc
eea777c9fc chore(deadcode): trim stale facade re-exports 2026-06-22 01:16:08 +08:00
Vincent Koc
0b28a72be1 fix(test): reject kova help value bypasses 2026-06-21 19:07:02 +02:00
Vincent Koc
adcba85264 fix(test): reject cpuprofile limit help tokens 2026-06-21 18:56:26 +02:00
Vincent Koc
5b79fa13e2 chore(deadcode): trim doctor alias wrappers 2026-06-22 00:54:26 +08:00
Vincent Koc
124ea48549 fix(test): reject docker timing flag limits 2026-06-21 18:49:24 +02:00
Vincent Koc
12756fc4c8 fix(test): reject env report flag paths 2026-06-21 18:46:05 +02:00
Vincent Koc
5bf459e23b fix(test): reject attestation platform flags 2026-06-21 18:43:05 +02:00
Vincent Koc
d64a27feeb chore(deadcode): drop node daemon runtime alias 2026-06-22 00:41:56 +08:00
Vincent Koc
6c42f73619 fix(test): reject i18n report flag values 2026-06-21 18:37:35 +02:00
Vincent Koc
d460f00eb9 fix(test): reject metadata ref flags 2026-06-21 18:33:10 +02:00
Vincent Koc
b83dce7b33 fix(test): reject rpc rtt flag values 2026-06-21 18:28:28 +02:00
Vincent Koc
d3c907193f fix(test): route qa otel smoke parser 2026-06-21 18:26:26 +02:00
Vincent Koc
b47c930e7e chore(deadcode): trim runtime plugin selection wrappers 2026-06-22 00:24:26 +08:00
Vincent Koc
0befd3c8f2 fix(test): route android release wrappers 2026-06-21 18:19:07 +02:00
Vincent Koc
c2ee9b0be8 fix(gateway): preserve owner MCP tools for agent RPC 2026-06-21 18:14:38 +02:00
Vincent Koc
9d27583190 fix(test): route release signing args 2026-06-21 18:10:35 +02:00
Vincent Koc
04c8c50cc4 fix(test): route testbox env hydration 2026-06-21 18:04:47 +02:00
Vincent Koc
5abf4ce2e2 chore(deadcode): trim reply runtime dead helpers 2026-06-22 00:03:31 +08:00
Vincent Koc
07d5cdec99 fix(test): route ios release wrappers 2026-06-21 17:59:21 +02:00
Vincent Koc
f5f23e739e fix(test): route proxy CA installer 2026-06-21 17:51:44 +02:00
Vincent Koc
0c183283e5 fix(test): route release preflight script 2026-06-21 17:46:59 +02:00
Vincent Koc
514b3365b5 fix(deadcode): move restart sentinels to sqlite 2026-06-21 23:39:38 +08:00
Vincent Koc
2804c24dc6 fix(test): route plugin dependency helpers 2026-06-21 17:32:43 +02:00
Vincent Koc
94c7b5a874 fix(test): route release ref resolver 2026-06-21 17:27:29 +02:00
Vincent Koc
93ec8b8c5c fix(test): route install helper scripts 2026-06-21 17:14:12 +02:00
Vincent Koc
9d83eeaccf fix(test): route release wrapper scripts 2026-06-21 17:08:40 +02:00
Vincent Koc
33eb6ab9de fix(test): route release approval script 2026-06-21 16:59:51 +02:00
Vincent Koc
757ab933f4 fix(test): route release script owners 2026-06-21 16:53:49 +02:00
Vincent Koc
63fdc57b3a fix(test): route mac helper script owners 2026-06-21 16:39:41 +02:00
Vincent Koc
a39a3b74de fix(deadcode): move restart handoffs to sqlite 2026-06-21 22:36:42 +08:00
Vincent Koc
77a859f4ae fix(ci): route mac packaging scripts to macos checks 2026-06-21 16:32:57 +02:00
Peter Lee
84cf64770f fix(whatsapp): wire missing Baileys retry/cache hooks for group message reliability (#94338)
Merged via squash.

Prepared head SHA: ee6de071f7
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-21 11:32:49 -03:00
Vincent Koc
0ad48dad2c fix(deadcode): move restart intents to sqlite 2026-06-21 22:29:05 +08:00
Marcus Castro
b50a5aebba fix(whatsapp): preserve native quote replies (#95483)
* fix(whatsapp): preserve native quote replies

* feat(plugin-sdk): add quote-reply live transport standard

* test(qa-lab): add WhatsApp quote-reply live scenarios
2026-06-21 11:26:58 -03:00
Vincent Koc
b84665222c fix(ci): regenerate Swift protocol model 2026-06-21 22:19:18 +08:00
snowzlmbot
6441e56465 fix(telegram): materialize streaming progress placeholders (#95183)
* fix(telegram): materialize streaming progress placeholders

* fix(telegram): cancel delayed progress drafts before final

* fix(telegram): satisfy progress placeholder lint

* fix(telegram): cancel delayed draft preview on clear

* refactor(telegram): simplify delayed preview flush

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-06-21 19:48:41 +05:30
Vincent Koc
6daabd23f8 fix(deadcode): move config health state to sqlite 2026-06-21 22:07:43 +08:00
Vincent Koc
2f33999898 fix(agents): retain bounded preflight history 2026-06-21 21:58:57 +08:00
Vincent Koc
a60947fb3e fix(agents): keep live tool-result prompts cache-stable 2026-06-21 21:54:35 +08:00
Peter Lee
ac5d219be3 fix(telegram): stop clearing registered webhook on channel restart (#94506)
Prepared head SHA: f721e14a92

Co-authored-by: xialonglee <li.xialong@xydigit.com>
2026-06-21 19:22:49 +05:30
Vincent Koc
ae41b00922 fix(deadcode): move plugin binding approvals to sqlite 2026-06-21 21:52:20 +08:00
Vincent Koc
b28e68e0ce fix(macos): validate DMG layout values 2026-06-21 15:48:52 +02:00
Josh Avant
5d1e649aea fix: route mobile exec approvals to reviewer device (#95175)
* fix: route mobile exec approvals to reviewer device

* fix: surface iOS approval events in foreground

* fix: forward codex approval reviewer device

* test: harden approval reviewer device contract

* test: cover reviewer approval fallback resolvers
2026-06-21 08:47:52 -05:00
Ayaan Zaidi
d6d17709e8 fix(telegram): clear progress draft before tool output (#93002) (thanks @zhangguiping-xydt) 2026-06-21 19:14:57 +05:30
Ayaan Zaidi
6fd6bddb92 style(telegram): trim progress draft dispatch comment 2026-06-21 19:14:57 +05:30
张贵萍0668001030
f4dee99574 fix(telegram): clear progress draft before tool artifacts 2026-06-21 19:14:57 +05:30
张贵萍0668001030
db33402af0 fix(telegram): clear progress draft before verbose tool output 2026-06-21 19:14:57 +05:30
Vincent Koc
088cab5ee4 fix(macos): prefer repo pnpm for packaging 2026-06-21 15:39:17 +02:00
Vincent Koc
bd74a62118 fix(install): use repo pnpm for git installs 2026-06-21 15:34:56 +02:00
Vincent Koc
9f888d95e0 fix(deadcode): move current conversation bindings to sqlite 2026-06-21 21:30:06 +08:00
Vincent Koc
11a2e03bd4 fix(install): detect package manager launcher names 2026-06-21 15:18:57 +02:00
Vincent Koc
5693fcda78 fix(ci): validate artifact package source sha 2026-06-21 15:08:18 +02:00
Vincent Koc
eb00d499d1 fix(deadcode): move update check state to sqlite 2026-06-21 20:57:41 +08:00
Vincent Koc
8a7906c716 fix(qa): reject fractional live token usage 2026-06-21 14:49:29 +02:00
Vincent Koc
c85113e30e fix(qa): escape tool coverage markdown cells 2026-06-21 14:46:38 +02:00
Vincent Koc
bdf81a825f fix(deadcode): move voicewake settings to sqlite 2026-06-21 20:45:17 +08:00
Vincent Koc
d9dfcd6c8a fix(qa): reject impossible confidence counts 2026-06-21 14:38:42 +02:00
Alex Knight
2cafbd0774 fix(plugins): reconcile managed npm root overrides with managed peer pins 2026-06-21 22:33:18 +10:00
Vincent Koc
54b2836eab test(scripts): stabilize gauntlet termination timing 2026-06-21 14:27:10 +02:00
Vincent Koc
5b6f4b2919 fix(test): reject gauntlet flag values 2026-06-21 14:21:16 +02:00
Vincent Koc
c037a34ba7 fix(security): ignore Docker rerun artifact commands 2026-06-21 14:14:57 +02:00
Vincent Koc
12c34fc3a9 fix(security): bound trusted package URL prefixes 2026-06-21 13:51:24 +02:00
Vincent Koc
e366349730 test(deadcode): reuse gateway restart intent writer 2026-06-21 19:49:47 +08:00
Vincent Koc
89a73d08c8 test(deadcode): dedupe wizard prompter helpers 2026-06-21 19:49:46 +08:00
Vincent Koc
e6f41a4df0 fix(test): reject loose env report limits 2026-06-21 13:37:55 +02:00
Vincent Koc
b7fef7fca6 fix(mac): clean failed dSYM merges 2026-06-21 13:32:47 +02:00
Vincent Koc
d1cbe29f3d fix(qa): require sampled Kova metric counts 2026-06-21 13:24:30 +02:00
Alex Knight
9dbc21d283 fix(telegram): materialize rich message line breaks as <br>
Bot API 10.1 rich messages parse structured HTML, so bare newlines
collapse as insignificant whitespace and flatten multi-paragraph replies
and bullet runs into a single line. Materialize logical line breaks as
<br> in prepareTelegramRichHtml -- the shared rich send/edit/draft
chokepoint covering both the Markdown and explicit-HTML text modes --
while keeping newlines literal inside code/pre/math and where they only
separate structural tags (block elements plus table/figure/details
container children, so pretty-printed rich HTML keeps valid markup).

Refs #95409
2026-06-21 21:22:33 +10:00
Vincent Koc
5e86c7eef4 fix(qa): reject non-object mock Anthropic JSON 2026-06-21 13:13:37 +02:00
Vincent Koc
6f3af56952 fix(qa): reject non-object mock OpenAI JSON 2026-06-21 13:10:50 +02:00
Vincent Koc
03ee22666b chore(deadcode): drop retired memory wiki vault metadata 2026-06-21 19:08:39 +08:00
Vincent Koc
0d351b9875 fix(ci): filter ClawSweeper comment dispatches before token minting (#95308)
Merged via squash.

Prepared head SHA: b5389b59e4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-21 19:05:08 +08:00
Vincent Koc
f69ba12a37 fix(qa): reject malformed mock OpenAI JSON 2026-06-21 12:58:20 +02:00
Vincent Koc
b796890b97 test(sdk): resolve Windows package taskkill path 2026-06-21 12:52:41 +02:00
Vincent Koc
b5fc9514c0 fix(qa): reject coerced evidence artifact indexes 2026-06-21 12:50:37 +02:00
Vincent Koc
2a140e6e6a fix(daemon): resolve Windows scheduled-task tools 2026-06-21 12:45:49 +02:00
Vincent Koc
6e5f4d685e chore(deadcode): remove stale readability compare script 2026-06-21 18:43:56 +08:00
Vincent Koc
600bace853 fix(qa): validate QA bus poll numbers 2026-06-21 12:41:22 +02:00
Vincent Koc
b8a5dac1a2 fix(qa-lab): resolve Windows PowerShell path 2026-06-21 12:40:57 +02:00
Vincent Koc
15c880aeff fix(infra): share trusted Windows process argv lookup 2026-06-21 12:31:27 +02:00
Vincent Koc
3a53eb5d77 test(deadcode): remove stale heartbeat transcript prune test 2026-06-21 18:25:28 +08:00
Vincent Koc
66e5cfdd86 fix(qa): reject QA Lab host port collisions 2026-06-21 12:23:52 +02:00
Vincent Koc
c4facb2bb3 fix(infra): resolve Windows port inspection tools 2026-06-21 12:22:18 +02:00
Vincent Koc
a70b34a3cb fix(qa-lab): resolve Windows node lookup tool 2026-06-21 12:14:39 +02:00
Vincent Koc
59bf85c586 chore(deadcode): remove stale cron usage helper 2026-06-21 18:12:36 +08:00
Vincent Koc
e486a1d1cf test(scripts): route Codex install assertions 2026-06-21 12:11:01 +02:00
Vincent Koc
72b9bc7303 fix(core): resolve Windows PATH locator tools 2026-06-21 12:09:33 +02:00
Vincent Koc
d3b44442f6 fix(infra): resolve Windows binary lookup tool 2026-06-21 12:02:02 +02:00
Vincent Koc
6ddbcbd460 chore(deadcode): remove stale proof scripts 2026-06-21 18:00:25 +08:00
Vincent Koc
7bd4aab21f test(scripts): route fixture common helper 2026-06-21 11:55:25 +02:00
Vincent Koc
a4c8b17b9e test(scripts): route Parallels lib helpers 2026-06-21 11:52:46 +02:00
Vincent Koc
d87f8325d0 test(agents): fix tools-manager mock typing 2026-06-21 11:49:00 +02:00
Vincent Koc
a5fde9119c fix(agents): resolve Windows extraction tools 2026-06-21 11:49:00 +02:00
Vincent Koc
e6c899dfa5 chore(deadcode): dedupe internal event formatting 2026-06-21 17:47:29 +08:00
Vincent Koc
425f512897 test(scripts): route ClawHub fixture server 2026-06-21 11:42:29 +02:00
Vincent Koc
735d70d9db test(extensions): pin Windows taskkill test roots 2026-06-21 11:37:59 +02:00
Vincent Koc
15300291ed fix(qa-matrix): resolve Windows taskkill path 2026-06-21 11:37:59 +02:00
Vincent Koc
eb7789c8cb test(scripts): route auth profile store assertions 2026-06-21 11:32:59 +02:00
Vincent Koc
f19052b3f3 test(scripts): route doctor install wrapper helper 2026-06-21 11:30:45 +02:00
Vincent Koc
61d1fd1f72 fix(qa-lab): resolve Windows taskkill path 2026-06-21 11:26:30 +02:00
Vincent Koc
1435fc123f test(scripts): route onboard config helpers 2026-06-21 11:23:08 +02:00
Vincent Koc
6c4028e073 fix(scripts): resolve Windows cmd shim launcher 2026-06-21 11:15:14 +02:00
Vincent Koc
9242137ca7 test(scripts): route config reload metadata helper 2026-06-21 11:13:13 +02:00
Vincent Koc
35d7cb0bff chore(deadcode): remove stale qa-matrix wrappers 2026-06-21 17:10:35 +08:00
Vincent Koc
c000e4811d fix(scripts): resolve Windows tools in kitchen sink walk 2026-06-21 11:06:56 +02:00
Vincent Koc
d25549f142 test(scripts): route plugin fixture commands 2026-06-21 11:04:38 +02:00
Vincent Koc
a192b2ea52 fix(windows): resolve taskkill in core spawns 2026-06-21 10:57:41 +02:00
Vincent Koc
7975ec0b11 test(scripts): route incremental line reader 2026-06-21 10:54:09 +02:00
Vincent Koc
e9b694ef9c fix(windows): resolve process inspection tools 2026-06-21 10:47:42 +02:00
Vincent Koc
3b332fd0a4 chore(deadcode): remove stale copilot doctor probes 2026-06-21 16:42:27 +08:00
Vincent Koc
7dd01d15c5 fix(windows): resolve cmd handoff path 2026-06-21 10:41:25 +02:00
Vincent Koc
675c56692a test(scripts): route mock OpenAI fixture helper 2026-06-21 10:31:00 +02:00
Vincent Koc
3f597619c8 test(scripts): route fixture config helper 2026-06-21 10:27:08 +02:00
Vincent Koc
91531ba35c fix(release): resolve taskkill in cross-os checks 2026-06-21 10:24:19 +02:00
Vincent Koc
206bbb01b0 fix(docs): resolve taskkill in i18n codex helper 2026-06-21 10:19:05 +02:00
Vincent Koc
c9758bf2a0 test(scripts): cover e2e text file utilities 2026-06-21 10:16:09 +02:00
Vincent Koc
282eb74128 fix(i18n): resolve taskkill in control ui runner 2026-06-21 10:14:00 +02:00
Vincent Koc
9940110b88 fix(telegram): resolve taskkill in crabbox proof 2026-06-21 10:10:27 +02:00
Vincent Koc
73b35cc3ca fix(bench): resolve taskkill in gateway child cleanup 2026-06-21 10:06:48 +02:00
Vincent Koc
ac0537e363 fix(dev): resolve taskkill in tui pty watcher 2026-06-21 10:02:31 +02:00
Vincent Koc
0321c04663 chore(deadcode): remove stale web artifact helpers 2026-06-21 16:00:38 +08:00
Vincent Koc
78b717a54c fix(plugins): resolve taskkill in bundled runtime smoke 2026-06-21 09:56:30 +02:00
Vincent Koc
7b00fd6c45 test(scripts): route plugin update registry helper 2026-06-21 09:54:59 +02:00
Vincent Koc
1f1155597b fix(telegram): resolve taskkill in credential helper 2026-06-21 09:52:23 +02:00
Vincent Koc
e9e42d5db4 fix(secret-provider): resolve taskkill in e2e cleanup 2026-06-21 09:49:07 +02:00
Vincent Koc
bc2f4ce923 test(scripts): route npm telegram runner 2026-06-21 09:47:52 +02:00
Vincent Koc
7dbae1b2cd fix(scripts): resolve taskkill in boundary artifacts 2026-06-21 09:44:42 +02:00
Vincent Koc
63f2c56222 test(scripts): route e2e helper owners 2026-06-21 09:43:00 +02:00
Vincent Koc
675cae58d7 fix(build): resolve taskkill in tsdown wrapper 2026-06-21 09:40:53 +02:00
Vincent Koc
c372f6ef0b fix(plugins): keep extension tests on public boundaries 2026-06-21 15:39:51 +08:00
Vincent Koc
1d4b712f9a fix(scripts): resolve taskkill in startup metadata 2026-06-21 09:37:35 +02:00
Vincent Koc
e016f0b496 fix(scripts): resolve taskkill in test group report 2026-06-21 09:34:05 +02:00
Vincent Koc
f8d2c4b25a chore(deadcode): remove stale migrate argv wrapper 2026-06-21 15:32:56 +08:00
Vincent Koc
b15f745a60 fix(package): resolve taskkill from system32 in candidate runner 2026-06-21 09:28:31 +02:00
Vincent Koc
8c9c8aad2e fix(qalab): resolve taskkill from system32 in cleanup probes 2026-06-21 09:24:16 +02:00
Vincent Koc
0a7b009647 fix(rpc): resolve taskkill from system32 in script probes 2026-06-21 09:18:06 +02:00
Vincent Koc
fc8542b377 chore(deadcode): remove stale internal exports 2026-06-21 15:14:36 +08:00
Vincent Koc
37a4b565ea test(scripts): mirror declaration route owners 2026-06-21 09:07:10 +02:00
Vincent Koc
6b0210a5fd fix(scripts): resolve taskkill from system32 2026-06-21 09:03:40 +02:00
Vincent Koc
c22e300084 fix(scripts): taskkill managed children via system32 on windows 2026-06-21 08:57:25 +02:00
Vincent Koc
5134dd0c54 test(scripts): route package runner declarations 2026-06-21 08:55:18 +02:00
Vincent Koc
830691b201 fix(memory-host-sdk): taskkill qmd process trees on windows 2026-06-21 08:51:36 +02:00
Vincent Koc
ab39bab52a chore(deadcode): dedupe session lineage patching 2026-06-21 14:48:50 +08:00
Vincent Koc
3f4d1cfcce test(scripts): run macOS app tests for app changes 2026-06-21 08:43:59 +02:00
Vincent Koc
06574920dd fix(sdk): taskkill package e2e trees on windows 2026-06-21 08:33:36 +02:00
Vincent Koc
d46b64df66 test(scripts): gate runtime sidecar baseline changes 2026-06-21 08:28:55 +02:00
Vincent Koc
b06e2f9149 fix(qa-matrix): taskkill scenario cli trees on windows 2026-06-21 08:25:25 +02:00
Vincent Koc
b574da57cf chore(deadcode): share levenshtein distance helper 2026-06-21 14:23:43 +08:00
Dallin Romney
bfe0caefd1 test: route broad flow tests out of unit-fast (#95499) 2026-06-20 23:22:39 -07:00
Vincent Koc
0004cfd59e test(scripts): route prompt snapshot helper changes 2026-06-21 08:16:47 +02:00
Vincent Koc
604aa30189 fix(qa): taskkill lifecycle probe trees on windows 2026-06-21 08:12:29 +02:00
Dallin Romney
5dd30c3995 test: fold HTTP API script proof into QA Lab (#94700)
* test: fold HTTP API script proof into QA Lab

* test: remove folded HTTP API script tests

* test: relax QA native scenario catalog inventory

* test: trim folded QA Lab script cruft

* test: align folded QA coverage ids

* test: keep native QA evidence out of parity tiers

* test: update mirrored QA routing expectation

* test: preserve chat tools profile build guard

* test: avoid overclaiming gateway tool API coverage

* test: pin folded QA coverage ids
2026-06-20 23:10:35 -07:00
Vincent Koc
5b22409389 fix(qa-lab): taskkill gateway children on windows 2026-06-21 08:07:00 +02:00
Vincent Koc
b970d57175 fix(qa-lab): taskkill timed-out cli trees on windows 2026-06-21 08:01:55 +02:00
Dallin Romney
9ab8e466d2 test(qa): make release scorecard categories explicit (#95406) 2026-06-20 23:01:23 -07:00
Vincent Koc
c43822077a fix(qa-lab): force taskkill scenario trees on windows 2026-06-21 07:56:03 +02:00
Vincent Koc
8797564254 chore(deadcode): share deferred test helper 2026-06-21 13:53:24 +08:00
Vincent Koc
273eb88874 fix(runtime-smoke): force taskkill bundled trees on windows 2026-06-21 07:50:42 +02:00
Vincent Koc
140a2fa520 fix(crabbox): force taskkill telegram proof trees on windows 2026-06-21 07:46:57 +02:00
Vincent Koc
c8b48c78d0 fix(qa): force taskkill telegram credential trees on windows 2026-06-21 07:44:10 +02:00
Vincent Koc
29033e67af fix(bench): force taskkill gateway bench trees on windows 2026-06-21 07:41:36 +02:00
Vincent Koc
e9a47fe554 test(scripts): route setup pnpm action helper 2026-06-21 07:40:06 +02:00
Vincent Koc
2f213a1606 test(scripts): route github yaml pinning guards 2026-06-21 07:40:06 +02:00
Vincent Koc
992ddf6310 test(scripts): cover workflow docker routes 2026-06-21 07:40:06 +02:00
Vincent Koc
7b259bd2a4 test(scripts): route github action metadata 2026-06-21 07:40:06 +02:00
Vincent Koc
e91ca8df86 test(scripts): route docs spellcheck config 2026-06-21 07:40:06 +02:00
Vincent Koc
af3e509ab8 fix(scripts): remove private auth monitor defaults 2026-06-21 07:40:06 +02:00
Vincent Koc
dec76bb5eb test(scripts): route pr wrapper scripts 2026-06-21 07:40:05 +02:00
Vincent Koc
862ef1cec1 test(scripts): route doctor switch shims 2026-06-21 07:40:05 +02:00
Vincent Koc
486c9e6ba3 test(scripts): route podman template metadata 2026-06-21 07:40:05 +02:00
Vincent Koc
b2d78abe94 test(scripts): route extensionless helper scripts 2026-06-21 07:40:05 +02:00
Vincent Koc
2f38b5aa2e test(scripts): route dockerfile metadata changes 2026-06-21 07:40:05 +02:00
Vincent Koc
4d17a52924 fix(scripts): force taskkill boundary node steps on windows 2026-06-21 07:36:02 +02:00
Vincent Koc
4b2298e8cb fix(package): force taskkill candidate runner trees on windows 2026-06-21 07:33:04 +02:00
Vincent Koc
f640ca11f9 fix(scripts): force taskkill run-with-env trees on windows 2026-06-21 07:30:11 +02:00
Vincent Koc
2029f87f29 fix(scripts): force taskkill managed trees on windows 2026-06-21 07:26:46 +02:00
Vincent Koc
ca1aa33eba fix(rpc): force taskkill rtt gateway trees on windows 2026-06-21 07:23:54 +02:00
Vincent Koc
5b212162d3 fix(rpc): force taskkill kitchen sink trees on windows 2026-06-21 07:21:04 +02:00
Vincent Koc
3df5207389 fix(testing): force taskkill group report trees on windows 2026-06-21 07:16:31 +02:00
Vincent Koc
78f30a010c fix(build): kill startup metadata trees on windows 2026-06-21 07:13:29 +02:00
Vincent Koc
83785a6e79 fix(build): kill tsdown trees on windows 2026-06-21 07:09:21 +02:00
Vincent Koc
195890f815 fix(dev): kill tui pty watch trees on windows 2026-06-21 07:06:48 +02:00
Vincent Koc
0f8df48a91 fix(release): forward-port 2026.6.9 closeout fixes 2026-06-21 13:05:11 +08:00
Vincent Koc
7b28b73e78 fix(e2e): preserve secret proof windows tree cleanup 2026-06-21 06:59:18 +02:00
Vincent Koc
3650766f26 fix(rpc): kill kitchen sink trees on windows 2026-06-21 06:53:14 +02:00
Vincent Koc
eb03d0ee2b chore(deadcode): share session cost totals 2026-06-21 12:52:23 +08:00
Vincent Koc
38c8b0c196 fix(crabbox): kill telegram proof trees on windows 2026-06-21 06:47:25 +02:00
Vincent Koc
0030a192c8 fix(qa): kill telegram credential trees on windows 2026-06-21 06:42:35 +02:00
Vincent Koc
34806b39cd fix(package): kill candidate resolver trees on windows 2026-06-21 06:37:51 +02:00
Vincent Koc
b0f21f8af7 fix(scripts): kill boundary prep trees on windows 2026-06-21 06:34:10 +02:00
Vincent Koc
5af318b95d fix(testing): kill group report trees on windows 2026-06-21 06:29:14 +02:00
Vincent Koc
8d2e6d7686 fix(scripts): kill run-with-env trees on windows 2026-06-21 06:24:47 +02:00
Vincent Koc
b039e949b6 chore(release): close out 2026.6.9 2026-06-21 12:24:15 +08:00
Vincent Koc
2ca5b7c93e fix(mac): retry DMG detach 2026-06-21 12:24:15 +08:00
Vincent Koc
51d1789cea fix(release): terminate command descendants on signal 2026-06-21 12:24:15 +08:00
Vincent Koc
89e73240a1 test(release): align full validation workflow contract 2026-06-21 12:24:15 +08:00
Vincent Koc
f3ee317f71 fix(ci): deduplicate release Telegram validation 2026-06-21 12:24:15 +08:00
Vincent Koc
ecb82f1be9 fix(plugins): restore StepFun ClawHub release 2026-06-21 12:24:14 +08:00
Vincent Koc
f1a48dac18 test(release): stabilize validation contracts 2026-06-21 12:24:14 +08:00
Vincent Koc
cba9c02095 chore(deadcode): share plugin snapshot fingerprint 2026-06-21 12:23:31 +08:00
Peter Steinberger
715dc718fc fix(opencode-go): align Kimi input with runtime 2026-06-21 00:22:52 -04:00
Vincent Koc
85f71f4c8f fix(scripts): kill managed child trees on windows 2026-06-21 06:19:23 +02:00
Peter Steinberger
66f84a9bf1 fix(opencode-go): add current Go models
Co-authored-by: samson1357924 <samson1357924@gmail.com>
2026-06-21 00:06:55 -04:00
Zak
50c2cc6a45 fix(zai): expose GLM-5.2 reasoning levels [AI-assisted] (#94136)
Merged via squash.

Prepared head SHA: 432214158a
Co-authored-by: BorClaw <268442329+BorClaw@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:57:49 -04:00
Chunyue Wang
e3ccf8743f fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models (#94067)
Merged via squash.

Prepared head SHA: 079347b8b8
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:56:53 -04:00
Vincent Koc
d4c2fa7aed chore(deadcode): drop unused sdk specifier helper 2026-06-21 11:50:51 +08:00
Nik
6c1041339d fix(agents): classify Zhipu GLM overload as overloaded for failover (#93241)
Merged via squash.

Prepared head SHA: db79e94c89
Co-authored-by: 0xghost42 <151941421+0xghost42@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:39 -04:00
huangjianxiong
db54a3268b fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models (#94461)
Merged via squash.

Prepared head SHA: 445a418187
Co-authored-by: Pandah97 <80405497+Pandah97@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:08 -04:00
Vincent Koc
9750d887f5 chore(deadcode): dedupe plugin lookup table types 2026-06-21 11:08:34 +08:00
Vincent Koc
fce586538a chore(deadcode): drop unused i18n config type 2026-06-21 10:45:56 +08:00
1118 changed files with 37330 additions and 34058 deletions

View File

@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const repo = "openclaw/openclaw";
const commitAssociationQueryBatchSize = 20;
const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]);
const nonEditorialTypes = new Set([
"build",
@@ -618,13 +619,25 @@ function graphql(query) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
return githubApi(["graphql", "-f", `query=${query}`]).data;
const response = githubApi(["graphql", "-f", `query=${query}`]);
if (response?.data && typeof response.data === "object") {
return response.data;
}
const errors = Array.isArray(response?.errors)
? response.errors.map((error) => error?.message).filter(Boolean)
: [];
const detail = [...errors, response?.message].filter(Boolean).join("\n");
throw new Error(
detail
? `GitHub GraphQL response did not include data:\n${detail}`
: "GitHub GraphQL response did not include data.",
);
} catch (error) {
lastError = error;
const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
// Historical ranges batch hundreds of objects; only retry transient transport failures.
if (
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|error connecting to api\.github\.com|Unexpected token '<')/i.test(
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test(
message,
)
) {
@@ -657,8 +670,8 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
}
}
for (let index = 0; index < commitHashes.length; index += 40) {
const chunk = commitHashes.slice(index, index + 40);
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
const fields = chunk
.map(
(hash, offset) =>

View File

@@ -81,9 +81,27 @@ jobs:
repositories: clawsweeper
permission-contents: write
- name: Pre-filter ClawSweeper comment
id: comment_filter
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
echo "is_command=true" >> "$GITHUB_OUTPUT"
else
echo "is_command=false" >> "$GITHUB_OUTPUT"
fi
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
@@ -213,7 +231,11 @@ jobs:
fi
- name: Acknowledge and dispatch ClawSweeper comment
if: ${{ github.event_name == 'issue_comment' }}
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true'
}}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
@@ -232,10 +254,6 @@ jobs:
. "$RUNNER_TEMP/github-api-backoff.sh"
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \

View File

@@ -70,7 +70,7 @@ on:
default: ""
type: string
npm_telegram_package_spec:
description: Optional published package spec for the package Telegram E2E lane
description: Optional published package spec for the focused package Telegram E2E rerun
required: false
default: ""
type: string
@@ -95,7 +95,7 @@ on:
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the package Telegram E2E lane
description: Provider mode for the focused package Telegram E2E rerun
required: false
default: mock-openai
type: choice
@@ -103,7 +103,7 @@ on:
- mock-openai
- live-frontier
npm_telegram_scenario:
description: Optional comma-separated Telegram scenario ids for the package Telegram lane
description: Optional comma-separated Telegram scenario ids for the focused package Telegram E2E rerun
required: false
default: ""
type: string
@@ -200,14 +200,16 @@ jobs:
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published release package: \`${RELEASE_PACKAGE_SPEC}\`"
fi
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
if [[ "$RERUN_GROUP" == "npm-telegram" && -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
elif [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
elif [[ "$RERUN_GROUP" == "npm-telegram" && -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${RELEASE_PACKAGE_SPEC}\`"
elif [[ "$RERUN_GROUP" == "all" && "$RELEASE_PROFILE" == "full" ]]; then
echo "- Package Telegram E2E: parent \`release-package-under-test\` artifact"
elif [[ "$RERUN_GROUP" == "npm-telegram" ]]; then
echo "- Package Telegram E2E: focused rerun requires \`release_package_spec\` or \`npm_telegram_package_spec\`"
elif [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "release-checks" || "$RERUN_GROUP" == "package" ]]; then
echo "- Package Telegram E2E: OpenClaw Release Checks Package Acceptance"
else
echo "- Package Telegram E2E: skipped unless \`release_profile=full\`, \`release_package_spec\`, or \`npm_telegram_package_spec\` is provided"
echo "- Package Telegram E2E: skipped by rerun group"
fi
if [[ -n "${EVIDENCE_PACKAGE_SPEC// }" ]]; then
echo "- Private evidence package proof: \`${EVIDENCE_PACKAGE_SPEC}\`"
@@ -764,80 +766,10 @@ jobs:
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
packages: write
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
package_sha256: ${{ steps.package.outputs.sha256 }}
package_version: ${{ steps.package.outputs.package_version }}
source_sha: ${{ steps.package.outputs.source_sha }}
steps:
- name: Checkout trusted workflow ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set artifact metadata
id: artifact
run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "false"
- name: Resolve release package artifact
id: package
shell: bash
env:
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
node scripts/resolve-openclaw-package-candidate.mjs \
--source ref \
--package-ref "$PACKAGE_REF" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")"
version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")"
source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
{
echo "## Release package artifact"
echo
echo "- Artifact: \`release-package-under-test\`"
echo "- Package ref: \`$PACKAGE_REF\`"
echo "- SHA-256: \`$digest\`"
echo "- Version: \`$version\`"
echo "- Source SHA: \`$source_sha\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release package artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-package-under-test
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/package-candidate.json
if-no-files-found: error
npm_telegram:
name: Run package Telegram E2E
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
needs: [resolve_target]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'npm-telegram' && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
@@ -853,8 +785,6 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}
PACKAGE_ARTIFACT_NAME: ${{ needs.prepare_release_package.outputs.artifact_name }}
PREPARE_PACKAGE_RESULT: ${{ needs.prepare_release_package.result }}
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
SCENARIO: ${{ inputs.npm_telegram_scenario }}
run: |
@@ -883,18 +813,7 @@ jobs:
return "$status"
}
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
echo "Full release Telegram requires either npm_telegram_package_spec or a prepared release-package-under-test artifact." >&2
exit 1
fi
args+=(
-f package_artifact_name="$PACKAGE_ARTIFACT_NAME"
-f package_artifact_run_id="${GITHUB_RUN_ID}"
-f package_label="full-release-${TARGET_SHA:0:12}"
)
fi
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
fi

View File

@@ -717,7 +717,6 @@ jobs:
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}

View File

@@ -6,34 +6,34 @@ Docs: https://docs.openclaw.ai
### Highlights
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
### Changes
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs from npm or ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @vincentkoc, and @JPKay-AI.
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix.
- Codex, observability, and skills: add automatic plugin approvals and SecretRefs, preserve ClawHub skill provenance, add OpenTelemetry log export, and expose remote-node execution to Codex when a node is connected. (#92625, #94324, #93283, #94561, #93654) Thanks @kevinslin, @kevinlin-openai, @momothemage, @nmccready-tars, @jesse-merhi, @vincentkoc, and @JPKay-AI.
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix. Thanks @vincentkoc.
### Fixes
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, keep safe cron delivery defaults, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650, #94453, #91685) Thanks @vincentkoc, @yetval, @ofan, @yaanfpv, @jincheng-xydt, @sallyom, @davectr, and @nxmxbbd.
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
@@ -57,6 +57,7 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
@@ -175,7 +176,7 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt and @vincentkoc and @0pen7ech.
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-icenter and @vincentkoc and @0pen7ech.
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
@@ -412,6 +413,53 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
- **PR #93685** refactor(auto-reply): add lifecycle storage seams. Thanks @jalehman.
- **PR #94349** fix(agents): preserve pending subagent completion announces. Related #93323. Thanks @sallyom and @oiGaDio.
- **PR #93174** test: fold channel message flows into qa e2e. Thanks @RomneyDa.
- **PR #94093** Prevent Codex thread rotation from losing next-step context. Thanks @VACInc.
- **PR #53920** fix(scripts): avoid mutating tracked auth-monitor template during setup. Thanks @JackWuGlobal.
- **PR #94702** Standardize QA coverage IDs on dotted names. Thanks @RomneyDa.
- **PR #81825** fix(skills/1password): stop forcing tmux for desktop app auth (#52540). Thanks @koshaji and @tylerbittner.
- **PR #94725** fix(doctor): warn on volatile SQLite state. Thanks @vincentkoc.
- **PR #88551** fix(agents): skip auth gate for CLI-owned transport. Thanks @yu-xin-c.
- **PR #88581** feat(commands): add /name to rename the current session from chat. Thanks @BSG2000.
- **PR #94324** feat(codex): support app-server SecretRefs. Thanks @kevinlin-openai and @kevinslin.
- **PR #90882** fix: add self-knowledge docs rule to system prompt. Related #90713. Thanks @SutraHsing.
- **PR #94684** fix: #80507 show dry-run output for message send/poll. Thanks @lzyyzznl and @YB0y.
- **PR #93823** fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply. Thanks @yetval.
- **PR #89203** refactor: route SDK session compatibility through seam. Thanks @jalehman.
- **PR #94453** fix: default cron runMode to "due" instead of "force" (#94270). Thanks @jincheng-xydt and @sallyom and @davectr.
- **PR #94746** fix(note): prevent clack from re-breaking copy-sensitive tokens. Related #94730. Thanks @xzh-icenter and @berkgungor.
- **PR #89904** refactor: route sdk session compatibility through accessor. Thanks @jalehman.
- **PR #86719** fix(skills): retarget stale plugin skill symlinks. Related #85925. Thanks @stevenepalmer and @shakkernerd.
- **PR #94337** fix(tui): show 0 not ? for fresh-session context tokens in footer. Thanks @mushuiyu886.
- **PR #94539** fix(android): group settings by intent. Thanks @Tosko4.
- **PR #92383** fix(gateway): never return an empty chat.history transcript. Thanks @Hidetsugu55.
- **PR #92574** test(browser): cover action-input CLI request bodies. Related #83877. Thanks @yu-xin-c and @davinci282828.
- **PR #92873** test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915). Thanks @liuhao1024 and @davinci282828.
- **PR #94257** fix(sessions): preserve Media\* index alignment when reading user-turn fields. Thanks @Nas01010101.
- **PR #94756** fix(codex): bound turn/start text when context budget is non-positive. Related #94748. Thanks @Nas01010101.
- **PR #94729** fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727). Thanks @liuhao1024 and @berkgungor.
- **PR #94790** feat(slack): log INFO receipt for inbound app_mention events. Related #94691. Thanks @ZengWen-DT and @BryceMurray.
- **PR #81696** fix: guard tool event callbacks (AI-assisted). Thanks @enjoylife1243.
- **PR #94809** chore: forward-port alpha release fixes.
- **PR #94612** fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468). Thanks @bbblending and @DINGDANGMAOUP.
- **PR #89806** fix(feishu): avoid axios interceptor internals. Related #83913. Thanks @sweetcornna and @davinci282828.
- **PR #91923** fix(ios): clean up notification settings state. Thanks @zats.
- **PR #91345** fix: suggest close CLI commands. Related #83999. Thanks @glenn-agent and @HannesOberreiter.
- **PR #94561** Add stdout diagnostics OTEL log exporter. Thanks @jesse-merhi.
- **PR #91013** fix(gateway): ignore stale abort markers for fresh chat events. Related #91012. Thanks @nxmxbbd.
- **PR #89279** fix(tasks): deliver ACP completions to bound Discord threads. Related #84022. Thanks @anyech and @h-mascot.
- **PR #91656** test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases. Related #91654. Thanks @SpecialLeon.
- **PR #94810** fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match. Related #94787. Thanks @ZOOWH and @parveshsaini.
- **PR #94737** fix(reply): clarify provider internal error copy. Thanks @snowzlmbot.
- **PR #94868** fix(channels): preserve command progress detail. Thanks @vincentkoc.
- **PR #94891** fix(telegram): send progress previews as html text. Thanks @obviyus.
- **PR #94683** fix(outbound): keep direct-only targets out of group sessions. Related #92384. Thanks @scotthuang and @haiwei01.
- **PR #92477** fix: migrate watch app to single-target app (Xcode 27+ compat). Thanks @zats and @joshavant.
- **PR #94812** test(perf): compare saved CLI startup benchmarks. Thanks @FelixIsaac.
- **PR #94856** fix(telegram): normalize all HTML tables before entity-escaping in rich messages. Related #94317. Thanks @zhangqueping and @jairrab.
- **PR #91685** fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket. Thanks @nxmxbbd.
## 2026.6.8

View File

@@ -2,5 +2,5 @@
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201
OPENCLAW_ANDROID_VERSION_NAME=2026.6.9
OPENCLAW_ANDROID_VERSION_CODE=2026060901

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
# OpenClaw iOS Changelog
## 2026.6.9 - 2026-06-20
Maintenance update for the current OpenClaw release.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on iPhone.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.6.2
OPENCLAW_MARKETING_VERSION = 2026.6.2
OPENCLAW_IOS_VERSION = 2026.6.9
OPENCLAW_MARKETING_VERSION = 2026.6.9
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -23,6 +23,10 @@ private struct WatchChatPreview {
var statusText: String?
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -895,26 +899,49 @@ final class NodeAppModel {
for await evt in stream {
if Task.isCancelled { return }
guard let payload = evt.payload else { continue }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
await self.handleOperatorGatewayServerEvent(evt)
}
}
}
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
guard let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]

View File

@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
isBackgrounded: false))
}
@Test func execApprovalEventIDDecodesGatewayPayload() {
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
let appModel = NodeAppModel()
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-event-resolved",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-event-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],

View File

@@ -1,3 +1,5 @@
OpenClaw is now available on iPhone.
Maintenance update for the current OpenClaw release.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.

View File

@@ -1,3 +1,3 @@
{
"version": "2026.6.2"
"version": "2026.6.9"
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.6.2</string>
<string>2026.6.9</string>
<key>CFBundleVersion</key>
<string>2026060200</string>
<string>2026060900</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -6592,6 +6592,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let turnsourceto: AnyCodable?
public let turnsourceaccountid: AnyCodable?
public let turnsourcethreadid: AnyCodable?
public let approvalreviewerdeviceids: [String]?
public let requiredeliveryroute: Bool?
public let suppressdelivery: Bool?
public let timeoutms: Int?
@@ -6618,6 +6619,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
turnsourceto: AnyCodable?,
turnsourceaccountid: AnyCodable?,
turnsourcethreadid: AnyCodable?,
approvalreviewerdeviceids: [String]?,
requiredeliveryroute: Bool? = nil,
suppressdelivery: Bool? = nil,
timeoutms: Int?,
@@ -6643,6 +6645,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.approvalreviewerdeviceids = approvalreviewerdeviceids
self.requiredeliveryroute = requiredeliveryroute
self.suppressdelivery = suppressdelivery
self.timeoutms = timeoutms
@@ -6670,6 +6673,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case approvalreviewerdeviceids = "approvalReviewerDeviceIds"
case requiredeliveryroute = "requireDeliveryRoute"
case suppressdelivery = "suppressDelivery"
case timeoutms = "timeoutMs"

View File

@@ -1,4 +1,4 @@
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
24f11880cec619997ff93d303c32431bf4fb2bfefb56c9f0ece35ff91b329a80 config-baseline.json
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl

View File

@@ -1,11 +1,11 @@
---
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
read_when:
- Setting up Slack or debugging Slack socket/HTTP mode
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
title: "Slack"
---
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
</Note>
### Relay mode
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
event over an authenticated websocket. The gateway continues to use its bot token for
outbound Slack Web API calls.
```json5
{
channels: {
slack: {
mode: "relay",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
},
},
}
```
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
router route table as part of the Slack authorization boundary: routed events enter the
normal Slack message handler as authorized activations. A router-provided `slack_identity`
in the websocket `hello` frame can set the default outbound username and icon; an explicit
identity supplied by the caller still wins. The relay connection reconnects with the same
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
it disconnects.
## Install
Install Slack before configuring the channel:
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
- `botToken` + `appToken` are required for Socket Mode.
- HTTP mode requires `botToken` + `signingSecret`.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
strings or SecretRef objects.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.

View File

@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Requirement:
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft

View File

@@ -177,7 +177,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
## Full Release Validation
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only when Telegram must prove a different package. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -230,8 +230,8 @@ canonical subscription `github-copilot` provider and is **never** selected by
The harness claims its provider, runtime, CLI session key, and auth profile
prefix in `extensions/copilot/doctor-contract-api.ts`, which
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
compaction, the declarative doctor contract, and the broader PI vs Codex vs
Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
## Compatibility contract

View File

@@ -302,13 +302,13 @@ Live transport runners should import the shared scenario ids, baseline
coverage helpers, and scenario-selection helper from
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | x | |
| Discord | x | x | x | | | | | | | | x |
| Slack | x | x | x | x | x | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | | | x | x | |
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Quote reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | ----------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
| Matrix | x | x | x | x | x | | x | x | x | x | | |
| Telegram | x | x | x | | | | | | | | x | |
| Discord | x | x | x | | | | | | | | | x |
| Slack | x | x | x | x | x | | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | x | | | x | x | |
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
Telegram, and other live transports share one explicit transport-contract checklist.
@@ -731,8 +731,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-whoami-command`, `whatsapp-context-command`,
`whatsapp-native-new-command`.
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
`whatsapp-reply-to-message`, `whatsapp-group-reply-to-message`,
`whatsapp-reply-context-isolation`, `whatsapp-reply-delivery-shape`,
`whatsapp-stream-final-message-accounting`.
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
@@ -749,9 +750,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
`whatsapp-approval-plugin-native`.
- Status reactions: `whatsapp-status-reactions`.
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 29 deterministic scenarios through the real WhatsApp transport while
The catalog currently contains 36 scenarios. The `live-frontier` default lane is
kept small at 10 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 31 deterministic scenarios through the real WhatsApp transport while
mocking only model output. Approval scenarios and a few heavier/blocking checks
remain explicit by scenario id.

View File

@@ -160,9 +160,10 @@ Legacy key migration:
Telegram:
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.

View File

@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
results and overflow recovery. Leave unset for the model-context auto cap:
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
model context window. `openclaw doctor --deep` prints the effective cap, and
doctor warns only when an explicit override is stale or has no effect.
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
long-context models, but the effective cap is still limited to about 30% of
the model context window. `openclaw doctor --deep` prints the effective cap,
and doctor warns only when an explicit override is stale or has no effect.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.

View File

@@ -160,8 +160,6 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
- Extra workspace dir detection (`~/openclaw`).
</Accordion>
<Accordion title="Gateway, services, and supervisors">
- Sandbox image repair when sandboxing is enabled.
@@ -469,14 +467,14 @@ That stages grounded durable candidates into the short-term dreaming store while
<Accordion title="10. systemd linger (Linux)">
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
</Accordion>
<Accordion title="11. Workspace status (skills, plugins, and legacy dirs)">
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
Doctor prints a summary of the workspace state for the default agent:
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- **Legacy workspace dirs**: warns when `~/openclaw` or other legacy workspace directories exist alongside the current workspace.
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
</Accordion>
<Accordion title="11b. Bootstrap file size">

View File

@@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
## Storage (Gateway host)
Wake words are stored on the gateway machine at:
Wake words and routing rules are stored in the gateway state database:
- `~/.openclaw/settings/voicewake.json`
- `~/.openclaw/state/openclaw.sqlite`
Shape:
The active tables are:
```json
{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 }
```
- `voicewake_triggers`
- `voicewake_routing_config`
- `voicewake_routing_routes`
Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are
doctor migration inputs only; runtime reads and writes the SQLite tables.
## Protocol

View File

@@ -145,6 +145,11 @@ local proof.
Use `definePluginEntry` for non-channel plugins. Channel plugins use
`defineChannelPluginEntry`.
Tool handlers may accept an optional fifth execution-context argument when
they need runtime-owned facts for the current call. The context includes the
active `runId`, effective `sessionKey`, ephemeral `sessionId`, owning
`agentId`, and ambient `deliveryContext` when those values are available.
</Step>
<Step title="Test the runtime">

View File

@@ -143,39 +143,12 @@ The native Codex app-server harness supports context engines that require
pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide
that host capability.
Codex thread bindings live in OpenClaw's SQLite plugin state and use the stable
agent-scoped OpenClaw session key, or an opaque conversation-binding id, as
their owner. Physical session ids fence delayed cleanup but may rotate without
losing the Codex thread. Context-engine compaction adopts the successor id
before continuing native Codex compaction. The bounded store rejects a new
binding at its safety limit instead of evicting an existing thread's continuity
record.
Conversation binds create or resume their Codex thread on the first bound
message after channel approval; an abandoned approval consumes no thread row.
That first message carries the prepared thread directly into its turn.
Subsequent messages use a metadata-only resume to subscribe the shared client,
then unsubscribe after the turn completes.
The runtime does not poll transcript-adjacent binding files. Upgrades from
releases that used `*.jsonl.codex-app-server.json` sidecars migrate them during
normal startup preflight. `openclaw doctor --fix` can run the same migration
manually.
Successfully matched sidecars are archived before the new runtime resumes their
threads. Migration imports durable thread ownership only; it does not infer
Codex context usage from OpenClaw counters or crawl Codex rollout files. For
agent-session harness bindings, the next resume attempts to restore a cached
native snapshot when Codex has one, and ongoing turns persist the current-context
usage reported by app-server notifications, not the cumulative thread lifetime
total. Conversation bindings
keep metadata-only resumes and leave continuity and compaction with the native
Codex thread. Conflicting or ambiguous sidecars stay in place with a warning for
operator review.
For Codex-backed agents, `/compact` starts native Codex app-server compaction on
the bound thread. OpenClaw bounds the request-acceptance RPC but does not wait
for compaction completion, restart the shared app-server, or fall back to a
context-engine or public OpenAI summarizer. If the native Codex thread binding
is missing or stale, the command fails closed so the operator sees the real
runtime boundary instead of silently switching compaction backends.
the bound thread. OpenClaw does not wait for completion, impose an OpenClaw
timeout, restart the shared app-server, or fall back to a context-engine or
public OpenAI summarizer. If the native Codex thread binding is missing or
stale, the command fails closed so the operator sees the real runtime boundary
instead of silently switching compaction backends.
```json5
{

View File

@@ -33,15 +33,12 @@ For the broader model/provider/runtime split, start with
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
`gitHubToken` env / auth-profile entry for headless / cron runs).
- A writable `copilotHome` directory. The harness defaults to
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
no explicit home is set.
`<agentDir>/copilot` when OpenClaw provides an agent directory, otherwise
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation.
`openclaw doctor` runs the plugin
[doctor contract](#doctor-and-probes) for the extension; failures there are
the canonical way to confirm the environment is ready before opting an agent
in.
[doctor contract](#doctor) for declarative session-state ownership and future
compatibility migrations. It does not run Copilot CLI environment probes.
## Plugin install
@@ -153,10 +150,6 @@ the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
Override with `copilotHome: <path>` on the attempt input when you need a
custom location (for example, a shared mount for migration).
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
pure shape check that validates which of the modes above will be used.
It does not perform a live SDK handshake.
## Configuration surface
The harness reads its config from per-attempt input
@@ -239,7 +232,7 @@ asserted in
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
under `describe("runSideQuestion")`.
## Doctor and probes
## Doctor
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
`src/plugins/doctor-contract-registry.ts`. It contributes:
@@ -251,18 +244,6 @@ under `describe("runSideQuestion")`.
runtime `copilot`; CLI session key `copilot`; auth profile
prefix `github-copilot:`.
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
that hosts (including `openclaw doctor`) can call to verify the environment:
| Probe | What it checks | Reasons it can fail |
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
real Copilot CLI or touch the host fs.
## Limitations
- The harness only claims the canonical `github-copilot` provider at MVP.

View File

@@ -291,7 +291,7 @@ Each entry lists the package, distribution route, and description.
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.

View File

@@ -12,7 +12,7 @@ Adds StepFun, StepFun Plan model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/stepfun-provider`
- Install route: npm
- Install route: npm; ClawHub: `clawhub:@openclaw/stepfun-provider`
## Surface

View File

@@ -238,9 +238,11 @@ releases.
`api.runtime.config.writeConfigFile(...)` directly. Prefer config that was
already passed into the active call path. Long-lived handlers that need the
current process snapshot can use `api.runtime.config.current()`. Long-lived
agent tools should use the tool context's `ctx.getRuntimeConfig()` inside
`execute` so a tool created before a config write still sees the refreshed
runtime config.
factory-created agent tools should use the tool factory context's
`ctx.getRuntimeConfig()` inside `execute` so a tool created before a config
write still sees the refreshed runtime config. For per-call run, session, or
delivery facts, use the tool execution context rather than closing over the
factory context.
Config writes must go through the transactional helpers and choose an
after-write policy:

View File

@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |

View File

@@ -151,6 +151,14 @@ Factories are still for fixed tool names. Use `definePluginEntry` directly when
the plugin computes tool names dynamically or combines tools with hooks,
services, providers, commands, or other runtime surfaces.
Factory context is construction-time state. Use it to decide whether the tool
exists for the run or to bind stable helpers. Per-call state belongs in the
execution context: static tool-plugin `execute` handlers receive it as fields on
their third `context` argument, and factory-created `AgentTool.execute`
handlers receive it as the optional fifth argument. The execution context
includes `runId`, effective `sessionKey`, `sessionId`, `agentId`, and
`deliveryContext` when OpenClaw knows those values.
## Return values
`defineToolPlugin` wraps plain return values into the OpenClaw tool-result

View File

@@ -28,8 +28,10 @@ The provider includes:
| ------------------------------- | --------------------- |
| `opencode-go/glm-5` | GLM-5 |
| `opencode-go/glm-5.1` | GLM-5.1 |
| `opencode-go/glm-5.2` | GLM-5.2 |
| `opencode-go/kimi-k2.5` | Kimi K2.5 |
| `opencode-go/kimi-k2.6` | Kimi K2.6 (3x limits) |
| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code |
| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro |
| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash |
| `opencode-go/mimo-v2-omni` | MiMo V2 Omni |
@@ -39,6 +41,8 @@ The provider includes:
| `opencode-go/qwen3.5-plus` | Qwen3.5 Plus |
| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus |
GLM-5.2 uses a 1M-token context window and supports up to 131K output tokens.
## Getting started
<Tabs>

View File

@@ -126,6 +126,11 @@ The manifest-backed catalog currently includes:
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
</Tip>
<Tip>
GLM-5.2 supports `off`, `low`, `high`, and `max` thinking levels. OpenClaw maps
`low` and `high` to Z.AI high reasoning effort, and `max` to max effort.
</Tip>
<Note>
Coding Plan setup defaults to `zai/glm-5.2`; general API setup keeps
`zai/glm-5.1`. Endpoint auto-detection falls back to `glm-5.1` or `glm-4.7`

View File

@@ -228,9 +228,9 @@ release state.
`OpenClaw Release Checks` for install smoke, package acceptance, cross-OS
package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full
runs always include exhaustive live/E2E and Docker release-path soak;
`run_release_soak=true` is retained for an explicit beta soak. With
`release_profile=full` and `rerun_group=all`, it also runs package Telegram
E2E against the `release-package-under-test` artifact from release checks.
`run_release_soak=true` is retained for an explicit beta soak. Package
Acceptance provides the canonical package Telegram E2E during candidate
validation, avoiding a second concurrent live poller.
Provide `release_package_spec` after publishing a beta to reuse the shipped
npm package across release checks, Package Acceptance, and package Telegram
E2E without rebuilding the release tarball. Provide
@@ -460,20 +460,16 @@ gh workflow run full-release-validation.yml \
```
The workflow resolves the target ref, dispatches manual `CI` with
`target_ref=<release-ref>`, dispatches `OpenClaw Release Checks`, prepares a
parent `release-package-under-test` artifact for package-facing checks, and
dispatches standalone package Telegram E2E when `release_profile=full` with
`rerun_group=all` or when `release_package_spec` or
`npm_telegram_package_spec` is set. `OpenClaw Release
Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker
release-path coverage when soak is enabled, Package Acceptance with Telegram
package QA, QA Lab parity, live Matrix, and live Telegram. A full/all run is
only acceptable when the `Full Release Validation` summary shows `normal_ci`,
`plugin_prerelease`, and `release_checks` as successful, unless a focused rerun
intentionally skipped the separate `Plugin Prerelease` child. In full/all mode,
the `npm_telegram` child must also be successful; outside full/all it is skipped
unless a published `release_package_spec` or `npm_telegram_package_spec` was
provided. The final
`target_ref=<release-ref>`, then dispatches `OpenClaw Release Checks`.
`OpenClaw Release Checks` fans out install smoke, cross-OS release checks,
live/E2E Docker release-path coverage when soak is enabled, Package Acceptance
with the canonical Telegram package E2E, QA Lab parity, live Matrix, and live
Telegram. A full/all run is only acceptable when the `Full Release Validation`
summary shows `normal_ci`, `plugin_prerelease`, and `release_checks` as
successful, unless a focused rerun intentionally skipped the separate `Plugin
Prerelease` child. Use the standalone `npm-telegram` child only for a focused
published-package rerun with `release_package_spec` or
`npm_telegram_package_spec`. The final
verifier summary includes slowest-job tables for each child run, so the release
manager can see the current critical path without downloading logs.
See [Full release validation](/reference/full-release-validation) for the
@@ -558,8 +554,8 @@ runs only the release-only plugin child, `release-checks` runs every release
box, and the narrower release groups are `install-smoke`, `cross-os`,
`live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`.
Focused `npm-telegram` reruns require `release_package_spec` or
`npm_telegram_package_spec`; full/all runs with `release_profile=full` use the
release-checks package artifact. Focused
`npm_telegram_package_spec`; full/all runs use the canonical package Telegram
E2E inside Package Acceptance. Focused
cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or
another OS/suite filter. QA release-check failures block normal release
validation, including required OpenClaw dynamic tool drift in the standard tier.

View File

@@ -53,8 +53,7 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns.
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.<br />**Rerun:** rerun the umbrella or provide `release_package_spec` for published-package reruns. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `release_package_spec` or `npm_telegram_package_spec` is set.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one.
@@ -76,7 +75,7 @@ or Docker-facing stages need it.
| Cross-OS | **Job:** `cross_os_release_checks`<br />**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`<br />**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.<br />**Rerun:** `rerun_group=cross-os`. |
| Repo and live E2E | **Job:** `Run repo/live E2E validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. |
| Docker release path | **Job:** `Run Docker release-path validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** release-path Docker chunks against the shared package artifact.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`. |
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.<br />**Rerun:** `rerun_group=package`. |
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**Tests:** offline plugin package fixtures, plugin update, the canonical mock-OpenAI Telegram package E2E, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.<br />**Rerun:** `rerun_group=package`. |
| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`<br />**Backing workflow:** direct jobs<br />**Tests:** candidate and baseline agentic parity packs, then the parity report.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`<br />**Backing workflow:** direct job<br />**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`<br />**Backing workflow:** direct job<br />**Tests:** live Telegram QA with Convex CI credential leases.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
@@ -107,9 +106,9 @@ commands with package artifact and image reuse inputs when available.
It does not remove normal full CI, Plugin Prerelease, install smoke, package
acceptance, or QA Lab. Stable and full profiles always run exhaustive repo/live
E2E and Docker release-path soak coverage. The beta profile can opt in with
`run_release_soak=true`. The full profile also makes the umbrella run package
Telegram E2E against the parent release package artifact when `rerun_group=all`,
so a full pre-publish candidate does not silently skip that Telegram package lane.
`run_release_soak=true`. Package Acceptance supplies the canonical package
Telegram E2E for every full candidate, so the umbrella does not duplicate that
live poller.
| Profile | Intended use | Included live/provider coverage |
| --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -189,7 +188,7 @@ workflow first, then rerun the smallest matching handle above.
Useful artifacts:
- `release-package-under-test` from the Full Release Validation parent and `OpenClaw Release Checks`
- `release-package-under-test` from `OpenClaw Release Checks`
- Docker release-path artifacts under `.artifacts/docker-tests/`
- Package Acceptance `package-under-test` and Docker acceptance artifacts
- Cross-OS release-check artifacts for each OS and suite

View File

@@ -72,10 +72,12 @@ Scope intent:
- `channels.telegram.accounts.*.webhookSecret`
- `channels.slack.botToken`
- `channels.slack.appToken`
- `channels.slack.relay.authToken`
- `channels.slack.userToken`
- `channels.slack.signingSecret`
- `channels.slack.accounts.*.botToken`
- `channels.slack.accounts.*.appToken`
- `channels.slack.accounts.*.relay.authToken`
- `channels.slack.accounts.*.userToken`
- `channels.slack.accounts.*.signingSecret`
- `channels.sms.authToken`

View File

@@ -295,6 +295,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.signingSecret",
"configFile": "openclaw.json",
@@ -323,6 +330,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.signingSecret",
"configFile": "openclaw.json",

View File

@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
the live tool-result cap from the effective model context window: `16000` chars
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
tokens, still bounded by the runtime context-share guard.

View File

@@ -34,7 +34,7 @@ title: "Thinking levels"
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Z.AI (`zai/*`) is binary (`on`/`off`) for most GLM models. GLM-5.2 is the exception: it exposes `/think off|low|high|max`, maps `low` and `high` to Z.AI `reasoning_effort: "high"`, and maps `max` to `reasoning_effort: "max"`.
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
## Resolution order

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.9",
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/admin-http-rpc",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw admin HTTP RPC endpoint",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"dependencies": {
"@anthropic-ai/sdk": "0.100.1",
"@aws/bedrock-token-generator": "1.1.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"repository": {
"type": "git",
@@ -24,10 +24,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1056.0",
"@aws-sdk/client-bedrock-runtime": "3.1056.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"repository": {
"type": "git",
@@ -28,10 +28,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.16.1"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"repository": {
"type": "git",
@@ -23,10 +23,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -10,6 +10,12 @@ import {
resolveClaudeCliExecutionArgs,
} from "./cli-shared.js";
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
}
describe("normalizeClaudePermissionArgs", () => {
it("leaves args alone when they omit permission flags", () => {
expect(
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expectDefaultDisallowedTools(backend.config.args);
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
expectDefaultDisallowedTools(backend.config.resumeArgs);
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/arcee-provider",
"version": "2026.6.8"
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Arcee provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Azure Speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bonjour",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.6.8"
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Brave Search provider plugin for web search.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8"
"openclawVersion": "2026.6.9"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -9,6 +9,23 @@ const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
),
}));
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
@@ -29,19 +46,6 @@ beforeEach(() => {
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
});
function createDeferred<T = void>() {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
async function withIsolatedNoProxyEnv(fn: () => Promise<void>) {
const origNoProxy = process.env.NO_PROXY;
const origNoProxyLower = process.env.no_proxy;

View File

@@ -1,12 +1,11 @@
/**
* Browser plugin runtime lifecycle helpers for startup relay setup and shutdown
* cleanup.
* Browser plugin runtime lifecycle helpers for startup and shutdown cleanup.
*/
import type { Server } from "node:http";
import { getPwAiModule } from "./pw-ai-module.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
@@ -27,10 +26,6 @@ export async function createBrowserRuntimeState(params: {
onWarn: params.onWarn,
});
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
return state;

View File

@@ -19,7 +19,6 @@ const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, re
});
const {
ensureExtensionRelayForProfilesMock,
getPwAiModuleMock,
isPwAiLoadedMock,
startTrackedBrowserTabCleanupTimerMock,
@@ -28,7 +27,6 @@ const {
} = vi.hoisted(() => {
const trackedTabCleanupMockLocal = vi.fn();
return {
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
getPwAiModuleMock: vi.fn(),
isPwAiLoadedMock: vi.fn(() => false),
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
@@ -42,7 +40,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
}));
@@ -64,7 +61,6 @@ const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-r
beforeEach(() => {
resetHandlers();
registerUnhandledRejectionHandlerMock.mockClear();
ensureExtensionRelayForProfilesMock.mockClear();
getPwAiModuleMock.mockClear();
isPwAiLoadedMock.mockReset().mockReturnValue(false);
startTrackedBrowserTabCleanupTimerMock.mockClear();

View File

@@ -19,8 +19,7 @@ vi.mock("./server-context.js", () => ({
listKnownProfileNames: listKnownProfileNamesMock,
}));
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js");
const { stopKnownBrowserProfiles } = await import("./server-lifecycle.js");
beforeEach(() => {
createBrowserRouteContextMock.mockClear();
@@ -28,17 +27,6 @@ beforeEach(() => {
stopOpenClawChromeMock.mockClear();
});
describe("ensureExtensionRelayForProfiles", () => {
it("is a no-op after removing the Chrome extension relay path", async () => {
await expect(
ensureExtensionRelayForProfiles({
resolved: { profiles: {} } as never,
onWarn: vi.fn(),
}),
).resolves.toBeUndefined();
});
});
describe("stopKnownBrowserProfiles", () => {
it("stops all known profiles and ignores per-profile failures", async () => {
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);

View File

@@ -1,24 +1,13 @@
/**
* Browser server lifecycle helpers for relay setup and profile shutdown.
* Browser server lifecycle helpers for profile shutdown.
*/
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
/** Ensures extension relay compatibility hooks for configured profiles. */
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
}) {
// Intentional no-op: the Chrome extension relay path has been removed.
// runtime-lifecycle still calls this helper, so keep the stub until the next
// breaking cleanup rather than changing the call graph in a patch release.
}
/** Stops every known Browser profile during runtime shutdown. */
export async function stopKnownBrowserProfiles(params: {
getState: () => BrowserServerState | null;

View File

@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({
}),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => true),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async () => {
@@ -69,7 +68,6 @@ vi.mock("./server-context.js", () => ({
}));
vi.mock("./server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: vi.fn(async () => {}),
}));
@@ -85,7 +83,6 @@ describe("browser control auth bootstrap failures", () => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.resolveBrowserControlAuth.mockClear();
mocks.shouldAutoGenerateBrowserAuth.mockClear();
mocks.ensureExtensionRelayForProfiles.mockClear();
});
afterEach(async () => {
@@ -98,7 +95,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when auth bootstrap resolves empty auth in production-like mode", async () => {
@@ -111,7 +107,6 @@ describe("browser control auth bootstrap failures", () => {
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode has no resolved password", async () => {
@@ -123,7 +118,6 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
it("fails closed when password mode drops an inactive token but has no password", async () => {
@@ -136,6 +130,5 @@ describe("browser control auth bootstrap failures", () => {
const started = await startBrowserControlServerFromConfig();
expect(started).toBeNull();
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
});
});

View File

@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
resolveBrowserControlAuth: vi.fn(() => ({})),
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
stopKnownBrowserProfiles: vi.fn(async () => {}),
isChromeReachable: vi.fn(async () => false),
isChromeCdpReady: vi.fn(async () => false),
@@ -32,7 +31,6 @@ vi.mock("../browser/control-auth.js", () => ({
}));
vi.mock("../browser/server-lifecycle.js", () => ({
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
}));

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8"
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Cerebras provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/chutes-provider",
"version": "2026.6.8"
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Chutes.ai provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "2026.5.28"
},
"peerDependencies": {
"openclaw": ">=2026.6.8"
"openclaw": ">=2026.6.9"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8"
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Cloudflare AI Gateway provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex-supervisor",
"version": "2026.6.8",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Codex app-server fleet supervision plugin.",
"type": "module",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
/** Doctor contract hooks for Codex config, state migration, and route ownership. */
/**
* Doctor contract hooks for Codex plugin config migrations and session-route
* ownership warnings.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
@@ -48,7 +51,9 @@ export const legacyConfigRules: LegacyConfigRule[] = [
},
];
/** Removes retired Codex plugin config keys while preserving unrelated config. */
/**
* Removes retired Codex plugin config keys while preserving unrelated config.
*/
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
@@ -66,9 +71,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
const nextConfig = structuredClone(cfg) as OpenClawConfig & {
plugins?: Record<string, unknown>;
};
const nextPluginConfig = asRecord(
asRecord(asRecord(asRecord(nextConfig.plugins)?.entries)?.codex)?.config,
);
const nextPlugins = asRecord(nextConfig.plugins);
const nextEntries = asRecord(nextPlugins?.entries);
const nextEntry = asRecord(nextEntries?.codex);
const nextPluginConfig = asRecord(nextEntry?.config);
if (!nextPluginConfig) {
return { config: cfg, changes: [] };
}
@@ -115,5 +121,3 @@ export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"],
},
];
export { stateMigrations } from "./src/migration/session-binding-sidecars.js";

View File

@@ -1,18 +1,9 @@
// Codex tests cover harness plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import {
createCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
describe("Codex agent harness supports()", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
it("supports the canonical codex virtual provider", () => {
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
@@ -49,149 +40,8 @@ describe("Codex agent harness supports()", () => {
});
it("honors explicit provider id overrides", () => {
const narrowHarness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
providerIds: ["codex"],
});
const narrowHarness = createCodexAppServerAgentHarness({ providerIds: ["codex"] });
const result = narrowHarness.supports({ provider: "openai", requestedRuntime: "codex" });
expect(result.supported).toBe(false);
});
});
describe("Codex agent harness reset", () => {
it("uses the host agent for global session keys", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const identity = {
kind: "session" as const,
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
};
await bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-work", cwd: "/repo" },
});
await harness.reset?.({
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
reason: "reset",
});
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/stale" },
}),
).resolves.toBe(false);
const nextIdentity = { ...identity, sessionId: "session-2" };
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(false);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "reclaim-generation",
expectedPreviousSessionId: identity.sessionId,
}),
).resolves.toBe(true);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(true);
await expect(bindingStore.read(nextIdentity)).resolves.toMatchObject({
threadId: "thread-next",
});
});
it("accepts an absent binding but rejects a mismatched reset generation", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const current = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
};
await expect(
harness.reset?.({
agentId: "main",
sessionId: "missing-session",
sessionKey: "agent:main:missing",
reason: "reset",
}),
).resolves.toBeUndefined();
await bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey: current.sessionKey,
reason: "reset",
}),
).rejects.toThrow("binding generation changed");
await expect(bindingStore.read(current)).resolves.toMatchObject({ threadId: "thread-1" });
});
it("reclaims a stale generation left while the Codex plugin was unavailable", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-reset-"));
const storePath = path.join(stateDir, "sessions.json");
const sessionKey = "agent:main:main";
await fs.writeFile(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId: "session-2",
updatedAt: Date.now(),
},
}),
"utf8",
);
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: () => ({ session: { store: storePath } }),
});
const stale = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey,
};
await bindingStore.mutate(stale, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey,
reason: "reset",
}),
).resolves.toBeUndefined();
const current = { ...stale, sessionId: "session-2" };
await expect(bindingStore.read(current)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-delayed", cwd: "/repo" },
}),
).resolves.toBe(false);
await fs.rm(stateDir, { recursive: true, force: true });
});
});

View File

@@ -7,13 +7,11 @@ import type {
AgentHarnessCompactResult,
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
@@ -39,14 +37,12 @@ type CodexAppServerAgentHarness = AgentHarness & {
* Creates the Codex app-server harness used for attempts, side questions,
* compaction, reset, and disposal.
*/
export function createCodexAppServerAgentHarness(options: {
export function createCodexAppServerAgentHarness(options?: {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
resolveConfig?: () => OpenClawConfig | undefined;
bindingStore: CodexAppServerBindingStore;
}): AgentHarness {
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
@@ -75,7 +71,6 @@ export function createCodexAppServerAgentHarness(options: {
// cold provider catalog reads do not pull in the whole Codex runtime.
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -83,7 +78,6 @@ export function createCodexAppServerAgentHarness(options: {
runSideQuestion: async (params) => {
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
return runCodexAppServerSideQuestion(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -91,43 +85,20 @@ export function createCodexAppServerAgentHarness(options: {
compact: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
compactAfterContextEngine: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
allowNonManualNativeRequest: true,
});
},
reset: async (params) => {
if (params.sessionId) {
const { reclaimCurrentCodexSessionGeneration, sessionBindingIdentity } =
await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
let retired = await options.bindingStore.retireSessionGeneration(identity);
if (retired === "conflict") {
const reclaimed = await reclaimCurrentCodexSessionGeneration({
bindingStore: options.bindingStore,
identity,
config: options.resolveConfig?.(),
});
if (reclaimed) {
retired = await options.bindingStore.retireSessionGeneration(identity);
}
}
if (retired === "conflict") {
throw new Error(
`Codex binding generation changed before session ${params.sessionId} could reset`,
);
}
if (params.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
}
},
dispose: async () => {

View File

@@ -4,30 +4,10 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import plugin from "./index.js";
import {
createCodexAppServerBindingStore,
sessionBindingIdentity,
} from "./src/app-server/session-binding.js";
import {
createCodexTestBindingStateStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
const runCodexAppServerSideQuestionMock = vi.hoisted(() => vi.fn());
function createCodexTestRuntime(
current?: () => unknown,
stateStore = createCodexTestBindingStateStore(),
) {
return {
...(current ? { config: { current } } : {}),
state: {
openSyncKeyedStore: () => stateStore,
},
} as never;
}
vi.mock("./src/app-server/run-attempt.js", () => ({
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
}));
@@ -60,6 +40,7 @@ describe("codex plugin", () => {
const registerProvider = vi.fn();
const registerWebSearchProvider = vi.fn();
const on = vi.fn();
const onConversationBindingResolved = vi.fn();
plugin.register(
createTestPluginApi({
@@ -68,7 +49,7 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(),
runtime: {} as never,
registerAgentHarness,
registerCommand,
registerMediaUnderstandingProvider,
@@ -76,6 +57,7 @@ describe("codex plugin", () => {
registerProvider,
registerWebSearchProvider,
on,
onConversationBindingResolved,
}),
);
@@ -85,6 +67,9 @@ describe("codex plugin", () => {
| Record<string, unknown>
| undefined;
const inboundClaimRegistration = mockCall(on) as [unknown, unknown] | undefined;
const bindingResolvedRegistration = mockCall(onConversationBindingResolved) as
| [unknown]
| undefined;
expect(providerRegistration.id).toBe("codex");
expect(providerRegistration.label).toBe("Codex");
@@ -118,12 +103,33 @@ describe("codex plugin", () => {
expect(migrationRegistration?.label).toBe("Codex");
expect(inboundClaimRegistration?.[0]).toBe("inbound_claim");
expect(typeof inboundClaimRegistration?.[1]).toBe("function");
expect(typeof bindingResolvedRegistration?.[0]).toBe("function");
});
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
const registerProvider = vi.fn();
const api = createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerProvider,
on: vi.fn(),
});
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
plugin.register(api);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect((mockCallArg(registerProvider) as { id?: string } | undefined)?.id).toBe("codex");
});
it("claims the Codex routing providers by default", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
@@ -144,196 +150,8 @@ describe("codex plugin", () => {
expect(unsupported.supported).toBe(false);
});
it("clears only ended session binding rows in the owning agent scope", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!sessionEnd) {
throw new Error("missing Codex session_end hook");
}
const identity = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey: "agent:worker:session-1",
});
const setBinding = () =>
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
for (const reason of ["shutdown", "restart", "compaction", "unknown"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toMatchObject({
threadId: "thread-1",
});
}
for (const reason of ["new", "reset", "idle", "daily", "deleted"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
}
});
it("adopts compaction successors before delayed lifecycle cleanup", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((
event: {
messageCount: number;
compactedCount: number;
previousSessionId?: string;
},
ctx: { agentId?: string; sessionId?: string; sessionKey?: string },
) => Promise<void>)
| undefined;
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!afterCompaction || !sessionEnd) {
throw new Error("missing Codex compaction lifecycle hooks");
}
const sessionKey = "agent:worker:telegram:chat-1";
const previous = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey,
});
const successor = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-2",
sessionKey,
});
const newest = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-3",
sessionKey,
});
await bindingStore.mutate(previous, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(previous)).resolves.toBeUndefined();
await expect(bindingStore.read(successor)).resolves.toMatchObject({ threadId: "thread-1" });
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-2" },
{ agentId: "worker", sessionId: "session-3", sessionKey },
);
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(successor)).resolves.toBeUndefined();
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
await sessionEnd(
{ sessionId: "session-1", sessionKey, reason: "reset" },
{ agentId: "worker", sessionId: "session-1", sessionKey },
);
await sessionEnd(
{ sessionId: "session-2", sessionKey, reason: "compaction" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
expect(stateStore.entries()).toHaveLength(1);
});
it("ignores compaction for a session without a Codex binding", async () => {
const warn = vi.fn();
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
runtime: createCodexTestRuntime(),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((event: object, ctx: { sessionId?: string; sessionKey?: string }) => Promise<void>)
| undefined;
if (!afterCompaction) {
throw new Error("missing Codex after_compaction hook");
}
await afterCompaction(
{ previousSessionId: "session-1" },
{ sessionId: "session-2", sessionKey: "agent:main:main" },
);
expect(warn).not.toHaveBeenCalled();
});
it("enables the native hook relay for public Codex app-server attempts", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const result = { success: true };
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
@@ -342,7 +160,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "hello" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},
@@ -377,7 +194,11 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: { codexPlugins: { enabled: false } },
runtime: createCodexTestRuntime(() => liveConfig),
runtime: {
config: {
current: () => liveConfig,
},
} as never,
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
@@ -397,49 +218,14 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "calendar" },
{
bindingStore: expect.any(Object),
pluginConfig: liveConfig.plugins.entries.codex.config,
nativeHookRelay: { enabled: true },
},
);
});
it("does not resurrect startup Codex config after the live entry is removed", async () => {
const registerAgentHarness = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: { appServer: { mode: "yolo" } },
runtime: createCodexTestRuntime(() => ({ plugins: { entries: {} } })),
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on: vi.fn(),
}),
);
const harness = mockCallArg(registerAgentHarness) as ReturnType<
typeof createCodexAppServerAgentHarness
>;
runCodexAppServerAttemptMock.mockResolvedValueOnce({ success: true });
await harness.runAttempt({ prompt: "default policy" } as never);
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "default policy" },
expect.objectContaining({ pluginConfig: undefined }),
);
});
it("enables the native hook relay for public Codex side questions", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const runSideQuestion = harness["runSideQuestion"];
const result = { text: "ok" };
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
@@ -452,7 +238,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
{ question: "btw" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},

View File

@@ -4,72 +4,48 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildCodexProvider } from "./provider.js";
import {
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
createLazyCodexAppServerBindingStore,
type StoredCodexAppServerBinding,
} from "./src/app-server/session-binding-store.js";
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
import { createCodexCommand } from "./src/commands.js";
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
} from "./src/conversation-binding.js";
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
import {
createCodexCliSessionNodeHostCommands,
createCodexCliSessionNodeInvokePolicies,
} from "./src/node-cli-session-registration.js";
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
const ENDED_SESSION_REASONS: ReadonlySet<string> = new Set([
"new",
"reset",
"idle",
"daily",
"deleted",
]);
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and Codex-managed GPT model catalog.",
register(api) {
const runtimeConfigLoader = api.runtime.config?.current
? () => api.runtime.config?.current() as OpenClawConfig
: undefined;
const resolveCurrentConfig = () => runtimeConfigLoader?.();
const loadNodeCliSessions = () => import("./src/node-cli-sessions.js");
const resolveCurrentConfig = () =>
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
const resolveCurrentPluginConfig = () =>
// Codex plugin config can change at runtime; resolve from live config for
// harness attempts and binding claims instead of keeping startup values.
resolveLivePluginConfigObject(
runtimeConfigLoader,
resolveCurrentConfig,
"codex",
api.pluginConfig as Record<string, unknown>,
);
const bindingStore = createLazyCodexAppServerBindingStore(
api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
}),
);
) ?? api.pluginConfig;
api.registerAgentHarness(
createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
}),
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerWebSearchProvider(
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
@@ -83,43 +59,43 @@ export default definePluginEntry({
}
api.registerCommand(
createCodexCommand({
resolvePluginConfig: resolveCurrentPluginConfig,
pluginConfig: api.pluginConfig,
deps: {
bindingStore,
listCodexCliSessionsOnNode: async (params) =>
await (
await loadNodeCliSessions()
).listCodexCliSessionsOnNode({
runtime: api.runtime,
...params,
}),
resolveCodexCliSessionForBindingOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resolveCodexCliSessionForBindingOnNode({
runtime: api.runtime,
...params,
}),
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
codexPluginsManagementIo: {
readConfig: () => {
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
if (
!codexPlugins ||
typeof codexPlugins !== "object" ||
Array.isArray(codexPlugins)
) {
const plugins = (current as Record<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const block = codexPlugins as Record<string, unknown>;
const declared = block.plugins;
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
@@ -129,12 +105,17 @@ export default definePluginEntry({
// Create the nested plugin config path on demand so codex
// plugin commands can enable/update Codex-managed plugins.
const root = draft as Record<string, unknown>;
const pluginsBlock = (root.plugins ??= {}) as Record<string, unknown>;
const entries = (pluginsBlock.entries ??= {}) as Record<string, unknown>;
const codexEntry = (entries.codex ??= {}) as Record<string, unknown>;
const config = (codexEntry.config ??= {}) as Record<string, unknown>;
const codexPlugins = (config.codexPlugins ??= {}) as Record<string, unknown>;
codexPlugins.plugins ??= {};
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
@@ -143,58 +124,14 @@ export default definePluginEntry({
},
}),
);
api.on("inbound_claim", async (event, ctx) => {
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
return await handleCodexConversationInboundClaim(event, ctx, {
bindingStore,
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
pluginConfig: resolveCurrentPluginConfig(),
config: resolveCurrentConfig(),
resumeCodexCliSessionOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resumeCodexCliSessionOnNode({
runtime: api.runtime,
...params,
}),
});
});
api.on("after_compaction", async (event, ctx) => {
const previousSessionId = event.previousSessionId?.trim();
const sessionId = ctx.sessionId?.trim();
if (!previousSessionId || !sessionId || previousSessionId === sessionId) {
return;
}
const config = resolveCurrentConfig();
const sessionKey = ctx.sessionKey?.trim();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
});
const adopted = await bindingStore.adoptSessionGeneration(identity, previousSessionId);
if (adopted === "conflict") {
api.logger.warn?.(
`codex: could not adopt compacted session generation ${sessionId} (${adopted}); secondary native compaction will skip`,
);
}
});
api.on("session_end", async (event, ctx) => {
if (!event.reason || !ENDED_SESSION_REASONS.has(event.reason)) {
return;
}
const sessionKey = event.sessionKey ?? ctx.sessionKey;
const config = resolveCurrentConfig();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
await bindingStore.retireSessionGeneration(
sessionBindingIdentity({
sessionId: event.sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
}),
);
});
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
},
});

View File

@@ -2,25 +2,8 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
import { adaptCodexTestClientFactory } from "./src/app-server/test-support.js";
const EXPECTED_MEDIA_THREAD_CONFIG = {
project_doc_max_bytes: 0,
web_search: "disabled",
"tools.experimental_request_user_input.enabled": false,
"features.hooks": false,
"features.multi_agent": false,
"features.apps": false,
"features.plugins": false,
"features.image_generation": false,
"features.skill_mcp_dependency_install": false,
"features.memories": false,
"features.goals": false,
"features.code_mode": false,
"features.code_mode_only": false,
};
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
@@ -102,15 +85,13 @@ function createFakeClient(options?: {
inputModalities?: string[];
completeWithItems?: boolean;
notifyError?: string;
approvalRequestMethod?: string;
responseText?: string;
turnStartError?: Error;
preBindNotificationCount?: number;
interruptError?: Error;
unsubscribeError?: Error;
}) {
const notifications = new Set<(notification: CodexServerNotification) => void>();
const closeHandlers = new Set<() => void>();
const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
const requests: Array<{ method: string; params?: JsonValue }> = [];
const approvalResponses: JsonValue[] = [];
const request = vi.fn(async (method: string, params?: JsonValue) => {
requests.push({ method, params });
if (method === "model/list") {
@@ -123,60 +104,51 @@ function createFakeClient(options?: {
return threadStartResult();
}
if (method === "turn/start") {
if (options?.turnStartError) {
throw options.turnStartError;
}
if (options?.preBindNotificationCount) {
for (let index = 0; index < options.preBindNotificationCount; index += 1) {
for (const notify of notifications) {
notify({
method: "item/started",
params: { threadId: "thread-1", turnId: "turn-1" },
});
if (options?.approvalRequestMethod) {
for (const handler of requestHandlers) {
const response = handler({ method: options.approvalRequestMethod });
if (response !== undefined) {
approvalResponses.push(response);
}
}
return turnStartResult();
}
const emitTurnNotifications = () => {
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
willRetry: false,
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
});
}
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
willRetry: false,
},
});
}
};
emitTurnNotifications();
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
}
return turnStartResult(
options?.completeWithItems ? "completed" : "inProgress",
options?.completeWithItems
@@ -192,12 +164,6 @@ function createFakeClient(options?: {
: [],
);
}
if (method === "turn/interrupt" && options?.interruptError) {
throw options.interruptError;
}
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
throw options.unsubscribeError;
}
return {};
});
@@ -207,17 +173,14 @@ function createFakeClient(options?: {
notifications.add(handler);
return () => notifications.delete(handler);
},
addRequestHandler() {
return () => undefined;
},
addCloseHandler(handler: () => void) {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests };
return { client, requests, approvalResponses };
}
describe("codex media understanding provider", () => {
@@ -229,9 +192,11 @@ describe("codex media understanding provider", () => {
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const clientFactory = vi.fn(
async (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
clientFactory,
});
const cfg = {
auth: {
@@ -254,33 +219,42 @@ describe("codex media understanding provider", () => {
});
expect(result).toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
]);
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-agent",
cfg,
expect.objectContaining({ timeoutMs: 30_000 }),
{ timeoutMs: 30_000 },
);
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[0]?.params).toEqual({ limit: 100, cursor: null, includeHidden: true });
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
expect(requests[2]?.params).toEqual({
threadId: "thread-1",
@@ -288,6 +262,9 @@ describe("codex media understanding provider", () => {
{ type: "text", text: "Describe briefly.", text_elements: [] },
{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
],
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
model: "gpt-5.4",
effort: "low",
});
});
@@ -295,12 +272,8 @@ describe("codex media understanding provider", () => {
it("treats a blank agent directory as absent when starting the app-server", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
const cfg = {
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
};
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
const cfg = {};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -313,16 +286,11 @@ describe("codex media understanding provider", () => {
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-default-agent",
cfg,
expect.any(Object),
);
expect(requests[1]?.params).toEqual(
expect.objectContaining({ cwd: "/tmp/openclaw-default-agent/codex-media-home" }),
);
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
timeoutMs: 30_000,
});
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("preserves configured WebSocket transport for media turns", async () => {
@@ -402,7 +370,7 @@ describe("codex media understanding provider", () => {
try {
const { client } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImage?.({
@@ -425,97 +393,33 @@ describe("codex media understanding provider", () => {
}
});
it("starts the media deadline before client acquisition", async () => {
vi.useFakeTimers();
it("declines approval requests during image understanding", async () => {
const { client, approvalResponses } = createFakeClient({
approvalRequestMethod: "item/permissions/requestApproval",
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(
async () => await new Promise<CodexAppServerClient>(() => {}),
),
clientFactory: async () => client,
});
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 100,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
const rejected = expect(description).rejects.toThrow(
"Codex app-server image understanding timed out",
);
await vi.advanceTimersByTimeAsync(100);
await rejected;
});
it("retires a media client lease that resolves after its deadline", async () => {
let resolveLease!: (lease: {
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}) => void;
const pendingLease = new Promise<{
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}>((resolve) => {
resolveLease = resolve;
});
const clientLeaseFactory = vi.fn(async () => await pendingLease);
const provider = buildCodexMediaUnderstandingProvider({ clientLeaseFactory });
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 5,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
await expect(description).rejects.toThrow("Codex app-server image understanding timed out");
const { client } = createFakeClient();
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
resolveLease({ client, release, abandon });
await vi.waitFor(() => expect(abandon).toHaveBeenCalledOnce());
expect(release).not.toHaveBeenCalled();
});
it("releases the bounded route between isolated media calls", async () => {
const { client, requests } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const request = {
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
prompt: "Describe briefly.",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
};
});
const first = await provider.describeImage?.(request);
const second = await provider.describeImage?.(request);
expect(first?.text).toBe("A red square.");
expect(second?.text).toBe("A red square.");
expect(requests.filter((entry) => entry.method === "model/list")).toHaveLength(2);
expect(requests.filter((entry) => entry.method === "thread/start")).toHaveLength(2);
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
});
it("extracts text from terminal turn items", async () => {
const { client } = createFakeClient({ completeWithItems: true });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImages?.({
@@ -534,7 +438,7 @@ describe("codex media understanding provider", () => {
it("rejects text-only Codex app-server models before starting a turn", async () => {
const { client, requests } = createFakeClient({ inputModalities: ["text"] });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -555,7 +459,7 @@ describe("codex media understanding provider", () => {
it("surfaces Codex app-server turn errors", async () => {
const { client } = createFakeClient({ notifyError: "vision unavailable" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -572,107 +476,12 @@ describe("codex media understanding provider", () => {
).rejects.toThrow("vision unavailable");
});
it.each([
{
name: "structured rejection",
error: new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start"),
abandonCount: 0,
},
{
name: "ambiguous timeout",
error: new Error("turn/start timed out"),
abandonCount: 1,
},
])("handles $name with exact media lease ownership", async ({ error, abandonCount }) => {
const { client } = createFakeClient({ turnStartError: error });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toBe(error);
expect(abandon).toHaveBeenCalledTimes(abandonCount);
expect(release).toHaveBeenCalledTimes(1);
});
it("retires the media client when thread cleanup is unconfirmed", async () => {
const { client } = createFakeClient({ unsubscribeError: new Error("unsubscribe failed") });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).resolves.toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("retires the media client when an accepted turn cannot be interrupted", async () => {
const { client, requests } = createFakeClient({
preBindNotificationCount: 257,
interruptError: new Error("interrupt timeout"),
});
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toThrow("pre-bind notification buffer exceeded 256 entries");
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"turn/interrupt",
]);
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("runs structured extraction through the same bounded Codex app-server path", async () => {
const { client, requests } = createFakeClient({
responseText: '{"summary":"red square","tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.extractStructured?.({
@@ -713,21 +522,31 @@ describe("codex media understanding provider", () => {
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
const turnParams = requests[2]?.params as
| {
@@ -740,9 +559,9 @@ describe("codex media understanding provider", () => {
}
| undefined;
expect(turnParams?.threadId).toBe("thread-1");
expect(turnParams?.approvalPolicy).toBeUndefined();
expect(turnParams?.model).toBeUndefined();
expect(turnParams?.cwd).toBeUndefined();
expect(turnParams?.approvalPolicy).toBe("on-request");
expect(turnParams?.model).toBe("gpt-5.4");
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
expect(turnParams?.effort).toBe("low");
expect(turnParams?.input).toHaveLength(3);
expect(turnParams?.input?.[0]?.type).toBe("text");
@@ -765,7 +584,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":"only text"}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -785,7 +604,7 @@ describe("codex media understanding provider", () => {
it("returns a controlled error when structured JSON parsing fails", async () => {
const { client } = createFakeClient({ responseText: "not json" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -814,7 +633,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":123,"tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(

View File

@@ -1,35 +1,216 @@
/** Lazy registration facade for Codex-backed media understanding. */
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
/**
* Codex-backed media understanding provider for bounded image description and
* structured extraction turns.
*/
import {
type JsonSchemaObject,
validateJsonSchemaValue,
} from "openclaw/plugin-sdk/json-schema-runtime";
import type {
ImagesDescriptionRequest,
ImagesDescriptionResult,
MediaUnderstandingProvider,
StructuredExtractionRequest,
StructuredExtractionResult,
} from "openclaw/plugin-sdk/media-understanding";
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.js";
import {
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
} from "./src/app-server/bounded-turn.js";
import type { CodexUserInput } from "./src/app-server/protocol.js";
const DEFAULT_CODEX_IMAGE_MODEL =
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
FALLBACK_CODEX_MODELS[0]?.id;
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
/** Dependencies and plugin config for Codex media-understanding calls. */
export type CodexMediaUnderstandingProviderOptions = {
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
/** Builds a provider whose app-server implementation loads on first use. */
/**
* Builds the media-understanding provider that delegates image tasks to an
* isolated Codex app-server session.
*/
export function buildCodexMediaUnderstandingProvider(
options: CodexMediaUnderstandingProviderOptions = {},
): MediaUnderstandingProvider {
let runtime: Promise<typeof import("./src/media-understanding-provider.runtime.js")> | undefined;
const load = () => (runtime ??= import("./src/media-understanding-provider.runtime.js"));
return {
id: CODEX_PROVIDER_ID,
capabilities: ["image"],
...(DEFAULT_CODEX_IMAGE_MODEL ? { defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL } } : {}),
describeImage: async ({ buffer, fileName, mime, ...request }) =>
await (
await load()
).describeCodexImages({ ...request, images: [{ buffer, fileName, mime }] }, options),
describeImages: async (request) => await (await load()).describeCodexImages(request, options),
extractStructured: async (request) =>
await (await load()).extractCodexStructured(request, options),
describeImage: async (req) =>
describeCodexImages(
{
images: [
{
buffer: req.buffer,
fileName: req.fileName,
mime: req.mime,
},
],
provider: req.provider,
model: req.model,
prompt: req.prompt,
maxTokens: req.maxTokens,
timeoutMs: req.timeoutMs,
profile: req.profile,
preferredProfile: req.preferredProfile,
authStore: req.authStore,
agentDir: req.agentDir,
cfg: req.cfg,
},
options,
),
describeImages: async (req) => describeCodexImages(req, options),
extractStructured: async (req) => extractCodexStructured(req, options),
};
}
async function describeCodexImages(
req: ImagesDescriptionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<ImagesDescriptionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex image understanding requires model id.");
}
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authProfileStore: req.authStore,
options,
taskLabel: "image understanding",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
input: [
{ type: "text", text: buildCodexImagePrompt(req), text_elements: [] },
...req.images.map((image) => ({
type: "image" as const,
url: `data:${image.mime ?? "image/png"};base64,${image.buffer.toString("base64")}`,
})),
],
requiredModalities: ["text", "image"],
isolation: "configured-transport",
});
return { text, model };
}
async function extractCodexStructured(
req: StructuredExtractionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<StructuredExtractionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex structured extraction requires model id.");
}
const instructions = req.instructions.trim();
if (!instructions) {
throw new Error("Codex structured extraction requires instructions.");
}
if (req.input.length === 0) {
throw new Error("Codex structured extraction requires at least one input.");
}
if (!req.input.some((entry) => entry.type === "image")) {
throw new Error("Codex structured extraction requires at least one image input.");
}
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authProfileStore: req.authStore,
options,
taskLabel: "structured extraction",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
input: buildCodexStructuredInput(req),
requiredModalities: requiredStructuredModalities(),
isolation: "configured-transport",
});
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
}
function buildCodexImagePrompt(req: ImagesDescriptionRequest): string {
const prompt = req.prompt?.trim() || DEFAULT_CODEX_IMAGE_PROMPT;
if (req.images.length <= 1) {
return prompt;
}
return `${prompt}\n\nAnalyze all ${req.images.length} images together.`;
}
function requiredStructuredModalities(): string[] {
return ["text", "image"];
}
function buildCodexStructuredInput(req: StructuredExtractionRequest): CodexUserInput[] {
return [
{ type: "text", text: buildStructuredExtractionPrompt(req), text_elements: [] },
...req.input.map((entry) => {
if (entry.type === "text") {
return { type: "text" as const, text: entry.text, text_elements: [] };
}
return {
type: "image" as const,
url: `data:${entry.mime ?? "image/png"};base64,${entry.buffer.toString("base64")}`,
};
}),
];
}
function buildStructuredExtractionPrompt(req: StructuredExtractionRequest): string {
return [
req.instructions.trim(),
req.schemaName ? `Schema name: ${req.schemaName}` : undefined,
req.jsonSchema ? `JSON schema:\n${JSON.stringify(req.jsonSchema)}` : undefined,
req.jsonMode === false
? "Return the extraction as concise text."
: "Return valid JSON only. Do not wrap the JSON in Markdown fences.",
]
.filter((part): part is string => Boolean(part))
.join("\n\n");
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeStructuredExtractionResult(params: {
text: string;
model: string;
provider: string;
req: StructuredExtractionRequest;
}): StructuredExtractionResult {
const result: StructuredExtractionResult = {
text: params.text,
model: params.model,
provider: params.provider,
contentType: params.req.jsonMode === false ? "text" : "json",
};
if (params.req.jsonMode !== false) {
try {
result.parsed = JSON.parse(params.text);
} catch {
throw new Error("Codex structured extraction returned invalid JSON.");
}
if (isJsonSchemaObject(params.req.jsonSchema)) {
const validation = validateJsonSchemaValue({
schema: params.req.jsonSchema,
cacheKey: "codex.media-understanding.extractStructured",
value: result.parsed,
cache: false,
});
if (!validation.ok) {
const message = validation.errors.map((error) => error.text).join("; ") || "invalid";
throw new Error(`Codex structured extraction JSON did not match schema: ${message}`);
}
result.parsed = validation.value;
}
}
return result;
}

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.9",
"dependencies": {
"@openai/codex": "0.139.0",
"typebox": "1.1.39",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.9",
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
"repository": {
"type": "git",
@@ -34,10 +34,10 @@
]
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.8"
"openclawVersion": "2026.6.9"
},
"release": {
"publishToClawHub": true,

View File

@@ -4,10 +4,10 @@ import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
import { codexProviderDiscovery } from "./provider-discovery.js";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import type { listAllCodexAppServerModels } from "./src/app-server/models.js";
import type { listCodexAppServerModels } from "./src/app-server/models.js";
import {
createIsolatedCodexAppServerClient,
leaseSharedCodexAppServerClient,
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
@@ -26,8 +26,7 @@ function createFakeCodexClient(): CodexAppServerClient {
return {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
@@ -40,7 +39,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
};
async function listTestCodexAppServerModels(
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
options: Parameters<typeof listCodexAppServerModels>[0] = {},
) {
expect(options.sharedClient).toBe(false);
const client = await createIsolatedCodexAppServerClient({
@@ -184,33 +183,45 @@ describe("codex provider", () => {
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
});
it("delegates all-page discovery to one model lister call", async () => {
const listModels = vi.fn(async () => ({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
}));
it("pages through live discovery before building the provider catalog", async () => {
const listModels = vi
.fn()
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
],
nextCursor: "page-2",
})
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
});
const result = await buildCodexProviderCatalog({
env: {},
listModels,
});
expect(listModels).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(listModels, 0), {
cursor: undefined,
limit: 100,
sharedClient: false,
});
expectRecordFields(mockCallArg(listModels, 1), {
cursor: "page-2",
limit: 100,
sharedClient: false,
});
@@ -266,7 +277,7 @@ describe("codex provider", () => {
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await leaseSharedCodexAppServerClient({
await getSharedCodexAppServerClient({
startOptions: {
transport: "stdio",
command: "/tmp/openclaw-test-codex",

View File

@@ -18,11 +18,16 @@ import {
CODEX_PROVIDER_ID,
FALLBACK_CODEX_MODELS,
} from "./provider-catalog.js";
import type { CodexAppServerStartOptions } from "./src/app-server/config.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
import type {
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
@@ -34,6 +39,7 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
@@ -117,11 +123,6 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
}
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
const [{ resolveCodexAppServerRuntimeOptions }, { buildCodexAppServerUsageSnapshot }] =
await Promise.all([
import("./src/app-server/config.js"),
import("./src/app-server/rate-limits.js"),
]);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
timeoutMs: ctx.timeoutMs,
@@ -155,15 +156,13 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
export async function buildCodexProviderCatalog(
options: BuildCatalogOptions = {},
): Promise<{ provider: ModelProviderConfig }> {
const { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } =
await import("./src/app-server/config.js");
const config = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
let discovered: CodexAppServerModel[] = [];
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
discovered = await listModelsBestEffort({
listModels: options.listModels ?? listAllCodexAppServerModelsLazy,
listModels: options.listModels ?? listCodexAppServerModelsLazy,
timeoutMs,
startOptions: appServer.start,
onDiscoveryFailure: options.onDiscoveryFailure,
@@ -201,14 +200,22 @@ async function listModelsBestEffort(params: {
onDiscoveryFailure?: (error: unknown) => void;
}): Promise<CodexAppServerModel[]> {
try {
// The all-pages helper keeps one app-server client alive across pagination.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
startOptions: params.startOptions,
sharedClient: false,
});
return result.models.filter((model) => !model.hidden);
const models: CodexAppServerModel[] = [];
let cursor: string | undefined;
do {
// App-server model listing is paginated; collect every visible model so
// aliases and picker rows match the current Codex account.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
cursor,
startOptions: params.startOptions,
sharedClient: false,
});
models.push(...result.models.filter((model) => !model.hidden));
cursor = result.nextCursor;
} while (cursor);
return models;
} catch (error) {
params.onDiscoveryFailure?.(error);
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
@@ -218,14 +225,15 @@ async function listModelsBestEffort(params: {
}
}
async function listAllCodexAppServerModelsLazy(options: {
async function listCodexAppServerModelsLazy(options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}): Promise<CodexAppServerModelListResult> {
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
return listAllCodexAppServerModels(options);
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
return listCodexAppServerModels(options);
}
async function requestCodexAppServerRateLimitsLazy(options: {

View File

@@ -1,6 +1,9 @@
// Codex tests cover app server policy plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
import {
resolveCodexAppServerForModelProvider,
resolveCodexAppServerForOpenClawToolPolicy,
} from "./app-server-policy.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
describe("Codex app-server policy", () => {
@@ -66,4 +69,143 @@ describe("Codex app-server policy", () => {
expect(explicitEnv.approvalPolicy).toBe("never");
expect(explicitRequirements.approvalPolicy).toBe("never");
});
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "openai/gpt-5.5",
}).approvalsReviewer,
).toBe("auto_review");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "gpt-5.5",
}).approvalsReviewer,
).toBe("user");
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
).toBe("auto_review");
});
it("uses human approval for OpenAI-compatible custom endpoints", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
});
expect(appServer.approvalsReviewer).toBe("user");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
}).approvalsReviewer,
).toBe("user");
});
it("uses human approval instead of Codex Guardian for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
const resolved = resolveCodexAppServerForModelProvider({
appServer,
provider: "lmstudio",
});
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
appServer,
provider: "openrouter",
model: "openai/gpt-5.5",
});
expect(appServer.approvalsReviewer).toBe("auto_review");
expect(resolved.approvalPolicy).toBe("on-request");
expect(resolved.sandbox).toBe("workspace-write");
expect(resolved.approvalsReviewer).toBe("user");
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
});
it("infers custom providers from provider-qualified model refs", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("downgrades legacy guardian_subagent for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
pluginConfig: {
appServer: {
mode: "guardian",
approvalsReviewer: "guardian_subagent",
},
},
});
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
).toBe("user");
});
});

View File

@@ -2,10 +2,11 @@
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
* approvals.
*/
import type {
CodexAppServerRuntimeOptions,
CodexPluginConfig,
OpenClawExecPolicyForCodexAppServer,
import {
canUseCodexModelBackedApprovalsReviewerForModel,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
/**
@@ -44,6 +45,35 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
};
}
export function resolveCodexAppServerForModelProvider(params: {
appServer: CodexAppServerRuntimeOptions;
provider?: string;
model?: string;
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
}): CodexAppServerRuntimeOptions {
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
if (
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: explicitProvider,
model: params.model,
config: params.config,
env: params.env,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
})
) {
return params.appServer;
}
return {
...params.appServer,
approvalsReviewer: "user",
};
}
function isCodexAppServerPolicyMode(value: unknown): boolean {
return value === "guardian" || value === "yolo";
}
@@ -53,3 +83,12 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
);
}
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
const normalized = provider?.trim().toLowerCase();
return normalized || undefined;
}

View File

@@ -285,7 +285,8 @@ function matchesCurrentTurn(
if (!requestParams) {
return false;
}
const requestThreadId = readString(requestParams, "threadId");
const requestThreadId =
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
const requestTurnId = readString(requestParams, "turnId");
return requestThreadId === threadId && requestTurnId === turnId;
}

View File

@@ -2,41 +2,10 @@
import { describe, expect, it, vi } from "vitest";
import {
interruptCodexTurnBestEffort,
runCodexTurnStartWithLease,
settleCodexAppServerClientLease,
unsubscribeCodexThreadBestEffort,
validateCodexThreadCreationResponse,
} from "./attempt-client-cleanup.js";
import { CodexAppServerRpcError } from "./client.js";
describe("Codex app-server attempt client cleanup", () => {
it("keeps the client lease after a structured turn-start rejection", async () => {
const abandon = vi.fn(async () => undefined);
const error = new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start");
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw error;
}),
).rejects.toBe(error);
expect(abandon).not.toHaveBeenCalled();
});
it("abandons only the exact client lease after an ambiguous turn-start timeout", async () => {
const abandon = vi.fn(async () => undefined);
const otherAbandon = vi.fn(async () => undefined);
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw new Error("turn/start timed out");
}),
).rejects.toThrow("turn/start timed out");
expect(abandon).toHaveBeenCalledTimes(1);
expect(otherAbandon).not.toHaveBeenCalled();
});
it("interrupts turns with optional request timeout", () => {
const request = vi.fn(async () => ({}));
@@ -53,58 +22,7 @@ describe("Codex app-server attempt client cleanup", () => {
);
});
it("unsubscribes a retained thread when its create response is malformed", async () => {
const request = vi.fn(async () => ({}));
const abandon = vi.fn(async () => undefined);
const invalidResponse = { thread: { id: "thread-1" } };
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
invalidResponse,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("invalid thread/start response");
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(abandon).not.toHaveBeenCalled();
});
it.each([
["omits the retained thread id", {}, vi.fn(async () => ({}))],
[
"cannot confirm unsubscribe",
{ thread: { id: "thread-1" } },
vi.fn(async () => {
throw new Error("connection lost");
}),
],
])(
"retires the client when a malformed create response %s",
async (_label, response, request) => {
const abandon = vi.fn(async () => undefined);
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
response,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("subscription could not be released");
expect(abandon).toHaveBeenCalledOnce();
},
);
it("reports unsubscribe cleanup failures", async () => {
it("swallows unsubscribe cleanup failures", async () => {
const request = vi.fn(async () => {
throw new Error("already gone");
});
@@ -114,7 +32,7 @@ describe("Codex app-server attempt client cleanup", () => {
threadId: "thread-1",
timeoutMs: 123,
}),
).resolves.toBe(false);
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
@@ -122,31 +40,4 @@ describe("Codex app-server attempt client cleanup", () => {
{ timeoutMs: 123 },
);
});
it("returns leases only after thread cleanup is confirmed", async () => {
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
await settleCodexAppServerClientLease(
{ client: { request: vi.fn(async () => ({})) }, release, abandon } as never,
{ threadId: "thread-ok", timeoutMs: 123 },
);
expect(release).toHaveBeenCalledOnce();
expect(abandon).not.toHaveBeenCalled();
release.mockClear();
await settleCodexAppServerClientLease(
{
client: {
request: vi.fn(async () => {
throw new Error("unsubscribe failed");
}),
},
release,
abandon,
} as never,
{ threadId: "thread-stale", timeoutMs: 123 },
);
expect(release).not.toHaveBeenCalled();
expect(abandon).toHaveBeenCalledOnce();
});
});

View File

@@ -2,124 +2,60 @@
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
*/
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
import type { CodexAppServerClientLease } from "./shared-client.js";
import type { CodexAppServerClient } from "./client.js";
import {
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
/** Timeout for best-effort app-server turn interruption during cleanup. */
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
/** Timeout for best-effort thread unsubscribe during cleanup. */
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
/** The connection's thread-subscription ownership can no longer be proven. */
export class CodexAppServerUnsafeSubscriptionError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "CodexAppServerUnsafeSubscriptionError";
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
const closeable = client as {
close?: CodexAppServerClient["close"];
closeAndWait?: CodexAppServerClient["closeAndWait"];
};
if (typeof closeable.closeAndWait === "function") {
await closeable.closeAndWait();
return;
}
closeable.close?.();
}
export function isCodexAppServerUnsafeSubscriptionError(
error: unknown,
): error is CodexAppServerUnsafeSubscriptionError {
return error instanceof CodexAppServerUnsafeSubscriptionError;
}
/** A resume response may only describe the thread this connection retained. */
export function assertCodexThreadResumeSubscription(
requestedThreadId: string,
returnedThreadId: string,
): void {
if (returnedThreadId !== requestedThreadId) {
throw new CodexAppServerUnsafeSubscriptionError(
`Codex thread/resume returned ${returnedThreadId} for ${requestedThreadId}`,
);
export async function closeCodexStartupClientBestEffort(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
}
}
/** Retires the exact client lease when turn acceptance is ambiguous. */
export async function runCodexTurnStartWithLease<T>(
lease: CodexAppServerClientLease,
startTurn: () => Promise<T>,
): Promise<T> {
try {
return await startTurn();
} catch (error) {
// Structured RPC rejection happens before Codex accepts the turn. Transport,
// timeout, and abort failures may hide an accepted turn with an unknown id.
if (!(error instanceof CodexAppServerRpcError)) {
await lease.abandon();
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
throw error;
return;
}
}
/** Retries once when native work wins the race immediately before turn/start. */
export async function runCodexTurnStartWithNativeTurnRetry<T>(params: {
startTurn: () => Promise<T>;
waitForActiveTurnCompletion: () => Promise<boolean>;
afterActiveTurnCompletion?: () => Promise<void>;
onRetry?: () => void;
}): Promise<T> {
try {
return await params.startTurn();
} catch (error) {
if (!isCodexActiveTurnNotSteerableError(error)) {
throw error;
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
params.onRetry?.();
if (!(await params.waitForActiveTurnCompletion())) {
throw error;
}
await params.afterActiveTurnCompletion?.();
return await params.startTurn();
return;
}
}
/** True for Codex's structured rejection when native work already owns the thread. */
export function isCodexActiveTurnNotSteerableError(error: unknown): boolean {
if (!(error instanceof CodexAppServerRpcError) || !isJsonObject(error.data)) {
return false;
}
const info = error.data.codexErrorInfo;
return isJsonObject(info) && isJsonObject(info.activeTurnNotSteerable);
}
/** Validates a create response and retires the client unless cleanup is confirmed. */
export async function validateCodexThreadCreationResponse<T>(
owner: {
client: CodexAppServerClient;
abandon: () => Promise<void>;
},
response: unknown,
validate: (value: unknown) => T,
): Promise<T> {
try {
return validate(response);
} catch (error) {
const threadId = readCodexThreadCreationResponseId(response);
const released = threadId
? await unsubscribeCodexThreadBestEffort(owner.client, {
threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
})
: false;
if (released) {
throw error;
}
try {
await owner.abandon();
} catch (abandonError) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its client could not be retired",
{ cause: abandonError },
);
}
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its subscription could not be released",
{ cause: error },
);
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
@@ -148,56 +84,28 @@ export function interruptCodexTurnBestEffort(
}
}
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
export async function unsubscribeCodexThreadBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
timeoutMs: number;
},
): Promise<boolean> {
): Promise<void> {
try {
await client.request(
"thread/unsubscribe",
{ threadId: params.threadId },
{ timeoutMs: params.timeoutMs },
);
return true;
} catch (error) {
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
threadId: params.threadId,
error,
});
return false;
}
}
/** Returns one exact client lease to the pool only after subscription cleanup succeeds. */
export async function settleCodexAppServerClientLease(
lease: CodexAppServerClientLease,
params: {
threadId?: string;
timeoutMs: number;
abandon?: boolean;
},
): Promise<void> {
if (params.abandon) {
await lease.abandon();
return;
}
if (
params.threadId &&
!(await unsubscribeCodexThreadBestEffort(lease.client, {
threadId: params.threadId,
timeoutMs: params.timeoutMs,
}))
) {
await lease.abandon();
return;
}
lease.release();
}
/**
* Retires the shared client after a timed-out turn so later runs do not reuse a
* potentially wedged app-server connection.
@@ -208,9 +116,10 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: string;
turnId: string;
reason: string;
abandonClientLease: () => Promise<void>;
},
): Promise<void> {
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
const detachedSharedClient = Boolean(retiredSharedClient);
interruptCodexTurnBestEffort(client, {
threadId: params.threadId,
turnId: params.turnId,
@@ -220,10 +129,28 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
await params.abandonClientLease();
let closedClient = retiredSharedClient?.closed ?? false;
if (!detachedSharedClient) {
const close = (client as { close?: () => void }).close;
if (typeof close === "function") {
try {
close.call(client);
closedClient = true;
} catch (error) {
embeddedAgentLog.debug("codex app-server client close failed during timeout cleanup", {
threadId: params.threadId,
turnId: params.turnId,
error,
});
}
}
}
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
threadId: params.threadId,
turnId: params.turnId,
reason: params.reason,
detachedSharedClient,
closedClient,
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
});
}

View File

@@ -9,6 +9,7 @@ import {
isFileChangePatchUpdatedNotification,
isAssistantCommentaryCompletionNotification,
isNativeToolProgressNotification,
isNativeResponseStreamDeltaNotification,
isPendingOpenClawDynamicToolCompletionNotification,
isRawAssistantProgressNotification,
isRawReasoningCompletionNotification,
@@ -16,6 +17,7 @@ import {
isReasoningProgressNotification,
isReasoningItemCompletionNotification,
isRetryableErrorNotification,
isTurnNotification,
readCodexNotificationItem,
readNotificationItemId,
shouldDisarmAssistantCompletionIdleWatch,
@@ -23,7 +25,6 @@ import {
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
import type { CodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
import { isCodexNotificationForTurn } from "./notification-correlation.js";
import type { CodexServerNotification } from "./protocol.js";
type CodexExecutionPhase =
@@ -69,7 +70,7 @@ export function isTerminalCodexTurnNotificationForTurn(params: {
turnId: string;
currentPromptTexts: string[];
}): boolean {
if (!isCodexNotificationForTurn(params.notification.params, params.threadId, params.turnId)) {
if (!isTurnNotification(params.notification.params, params.threadId, params.turnId)) {
return false;
}
return (
@@ -104,15 +105,16 @@ export function applyCodexTurnNotificationState(params: {
turnCrossedToolHandoff: boolean;
} {
const { notification, turnWatches } = params;
const isCurrentTurnNotification = isCodexNotificationForTurn(
const isCurrentTurnNotification = isTurnNotification(
notification.params,
params.threadId,
params.turnId,
);
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
if (isCurrentTurnNotification) {
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
turnWatches.touchActivity(`notification:${notification.method}`, {
details: describeNotificationActivity(notification),
attemptProgress: true,
@@ -248,6 +250,7 @@ export function applyCodexTurnNotificationState(params: {
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
notification.method !== "turn/completed" &&
isCurrentTurnNotification &&
!isNativeResponseStreamDelta &&
!trackedDynamicToolCompletion &&
!rawToolOutputCompletion &&
!postToolProgressNeedsTerminalGuard &&

View File

@@ -1,6 +1,11 @@
/**
* Predicates and readers for Codex app-server notification envelopes.
*/
import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
describeCodexNotificationCorrelation,
isCodexNotificationForTurn,
} from "./notification-correlation.js";
import {
isJsonObject,
type CodexServerNotification,
@@ -211,6 +216,13 @@ export function isNativeToolProgressNotification(notification: CodexServerNotifi
}
}
/** Returns true for raw native response stream delta events. */
export function isNativeResponseStreamDeltaNotification(
notification: CodexServerNotification,
): boolean {
return notification.method.startsWith("response.") && notification.method.endsWith(".delta");
}
/** Returns true for file-change patch update notifications. */
export function isFileChangePatchUpdatedNotification(
notification: CodexServerNotification,
@@ -265,9 +277,74 @@ function readRawAssistantTextPreview(item: JsonObject): string | undefined {
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
}
/** Returns true when notification params correlate to a specific thread/turn. */
export function isTurnNotification(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
return isCodexNotificationForTurn(value, threadId, turnId);
}
/** Returns true when a correlated notification belongs to another active run. */
export function isCodexNotificationOutsideActiveRun(
correlation: ReturnType<typeof describeCodexNotificationCorrelation>,
): boolean {
const hasThreadScope = Boolean(correlation.threadId || correlation.nestedTurnThreadId);
if (!hasThreadScope) {
return false;
}
if (!correlation.matchesActiveThread) {
return true;
}
const hasTurnScope = Boolean(correlation.turnId || correlation.nestedTurnId);
return hasTurnScope && correlation.matchesActiveTurn === false;
}
/** Checks request params that must contain the current thread and turn ids. */
export function isCurrentThreadTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
return readString(value, "threadId") === threadId && readString(value, "turnId") === turnId;
}
/** Checks approval request params, accepting `conversationId` as thread id. */
export function isCurrentApprovalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
const requestThreadId = readString(value, "threadId") ?? readString(value, "conversationId");
return requestThreadId === threadId && readString(value, "turnId") === turnId;
}
/** Checks request params where `turnId` may be omitted or null for the thread. */
export function isCurrentThreadOptionalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value) || readString(value, "threadId") !== threadId) {
return false;
}
const requestTurnId = value.turnId;
return requestTurnId === null || requestTurnId === undefined || requestTurnId === turnId;
}
/** Returns true for app-server error notifications that will retry. */
export function isRetryableErrorNotification(value: JsonValue | undefined): boolean {
return isJsonObject(value) && value.willRetry === true;
if (!isJsonObject(value)) {
return false;
}
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
}
/** Returns true for terminal app-server thread status strings. */
@@ -342,6 +419,10 @@ function readString(record: JsonObject, key: string): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readBoolean(record: JsonObject, key: string): boolean | undefined {
return asBoolean(record[key]);
}
/** Reads a typed Codex item from notification params when id/type are present. */
export function readCodexNotificationItem(
params: JsonValue | undefined,

View File

@@ -9,16 +9,13 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerClient } from "./client.js";
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { threadStartResult } from "./run-attempt-test-harness.js";
import {
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import {
leaseSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
@@ -88,10 +85,12 @@ function startThreadWithHarness(
signal = new AbortController().signal,
overrides?: {
pluginConfig?: CodexPluginConfig;
attemptClientFactory?: (
harness: ClientHarness,
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
harness?: ClientHarness;
paths?: AttemptPaths;
skipStartSpy?: boolean;
onThreadReserved?: Parameters<typeof startCodexAttemptThread>[0]["onThreadReserved"];
},
) {
const harness = overrides?.harness ?? createClientHarness();
@@ -102,7 +101,8 @@ function startThreadWithHarness(
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
bindingStore: testCodexAppServerBindingStore,
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -125,11 +125,10 @@ function startThreadWithHarness(
sandboxExecServerEnabled: false,
sandbox: null,
contextEngineProjection: undefined,
startupTokenGuard: {},
startupTimeoutMs,
signal,
onStartupTimeout: vi.fn(),
onThreadReserved: overrides?.onThreadReserved,
spawnedBy: undefined,
});
return { harness, run };
@@ -171,13 +170,12 @@ describe("startCodexAttemptThread", () => {
vi.useRealTimers();
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
resetCodexTestBindingStore();
resetSharedCodexAppServerClientForTests();
clearSharedCodexAppServerClient();
});
afterEach(async () => {
vi.useRealTimers();
resetSharedCodexAppServerClientForTests();
clearSharedCodexAppServerClient();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const root of tempRoots) {
@@ -186,7 +184,7 @@ describe("startCodexAttemptThread", () => {
tempRoots.clear();
});
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
@@ -196,57 +194,25 @@ describe("startCodexAttemptThread", () => {
});
await expect(run).rejects.toThrow("Invalid bearer token");
expect(harness.process.stdin.destroyed).toBe(false);
});
it("retires the client when malformed startup cleanup cannot be confirmed", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: { thread: { id: "thread-malformed" } } });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("subscription could not be released");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("retires the client when route cleanup cannot release the subscription", async () => {
const { harness, run } = startThreadWithHarness(5_000, undefined, {
onThreadReserved: () => {
throw new Error("route integration failed");
},
});
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: threadStartResult("thread-route-failed") });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("Codex startup subscription cleanup failed");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("does not retire a peer-owned client after a structured startup rejection", async () => {
it("retires a failed startup client after another active lease releases", async () => {
const retained = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
@@ -262,16 +228,17 @@ describe("startCodexAttemptThread", () => {
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
retainedLease.release();
const nextLeasePromise = leaseSharedCodexAppServerClient({
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
const nextLease = await nextLeasePromise;
expect(nextLease.client).toBe(retained.client);
expect(startSpy).toHaveBeenCalledTimes(1);
nextLease.release();
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
@@ -293,20 +260,18 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("retires abandoned thread startup even when another lease shares the client", async () => {
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
@@ -317,9 +282,11 @@ describe("startCodexAttemptThread", () => {
const threadStart = await waitForThreadStart(retained);
await rejected;
expect(threadStart.id).toBeDefined();
expect(retained.process.stdin.destroyed).toBe(true);
retainedLease.release();
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
});
it("closes the shared app-server when startup times out during initialize", async () => {
@@ -344,37 +311,45 @@ describe("startCodexAttemptThread", () => {
).toBe(false);
});
it("releases a late startup lease without retiring a peer-owned initializing client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const paths = createAttemptPaths();
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const peerPromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
it("closes a startup client that arrives after startup timeout", async () => {
let observedFactoryOptions:
| {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
}
| undefined;
let resolveFactoryDone: () => void = () => undefined;
const factoryDone = new Promise<void>((resolve) => {
resolveFactoryDone = resolve;
});
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness,
paths,
skipStartSpy: true,
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
try {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
} finally {
resolveFactoryDone();
}
},
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await expect(run).rejects.toThrow("codex app-server startup timed out");
expect(harness.stdinDestroyed).toBe(false);
await answerInitialize(harness);
const peer = await peerPromise;
expect(peer.client).toBe(harness.client);
await new Promise<void>((resolve) => {
setImmediate(resolve);
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(startSpy).toHaveBeenCalledTimes(1);
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
await peer.abandon();
expect(harness.stdinDestroyed).toBe(true);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {

View File

@@ -11,15 +11,10 @@ import {
type resolveSandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
CodexAppServerUnsafeSubscriptionError,
isCodexAppServerUnsafeSubscriptionError,
unsubscribeCodexThreadBestEffort,
} from "./attempt-client-cleanup.js";
import { closeCodexStartupClientBestEffort } from "./attempt-client-cleanup.js";
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
@@ -57,23 +52,16 @@ import {
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import type { CodexAppServerBindingStore } from "./session-binding.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
clearSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import type { CodexAppServerStartupTokenGuard } from "./startup-binding.js";
import {
startOrResumeThread,
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import {
getCodexAppServerTurnRouter,
type CodexAppServerTurnRouter,
type CodexThreadRouteReservation,
} from "./turn-router.js";
import type { CodexNativeWebSearchSupport } from "./web-search.js";
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
@@ -81,15 +69,14 @@ type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
/** Resources and bindings returned after a Codex attempt thread starts. */
export type StartCodexAttemptThreadResult = {
turnRouter: CodexAppServerTurnRouter;
turnRoute: CodexThreadRouteReservation;
client: CodexAppServerClient;
thread: CodexAppServerThreadLifecycleBinding;
pluginAppServer: CodexAppServerRuntimeOptions;
sandboxEnvironment: CodexSandboxExecEnvironment | undefined;
environmentSelection: CodexTurnEnvironmentParams[] | undefined;
executionCwd: string;
sandboxPolicy: CodexSandboxPolicy | undefined;
clientLease: CodexAppServerClientLease;
mcpElicitationDelegationRequired: boolean;
releaseSharedClientLease: () => void;
restartContextEngineCodexThread: () => Promise<CodexAppServerThreadLifecycleBinding>;
};
@@ -98,8 +85,7 @@ export type StartCodexAttemptThreadResult = {
* run loop must later release.
*/
export async function startCodexAttemptThread(params: {
bindingStore: CodexAppServerBindingStore;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
attemptClientFactory: CodexAppServerClientFactory;
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
computerUseConfig: CodexComputerUseConfig;
@@ -125,26 +111,18 @@ export async function startCodexAttemptThread(params: {
sandboxExecServerEnabled: boolean;
sandbox: CodexSandboxContext;
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
expectedResumeThreadId?: string;
startupTokenGuard: CodexAppServerStartupTokenGuard;
startupTimeoutMs: number;
signal: AbortSignal;
onStartupTimeout: () => void | Promise<void>;
onThreadReserved?: (client: CodexAppServerClient, threadId: string) => () => void;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): Promise<StartCodexAttemptThreadResult> {
let mcpElicitationDelegationRequired = false;
let sharedClientLease: CodexAppServerClientLease | undefined;
let pluginAppServer = params.appServer;
let releaseSharedClientLease: (() => void) | undefined;
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
let startupAbandoned = false;
const startupAbandonController = new AbortController();
const abandonStartupAcquire = () => startupAbandonController.abort();
const abandonStartupClient = async () => {
const lease = sharedClientLease;
sharedClientLease = undefined;
if (lease) {
await lease.abandon();
}
};
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
try {
const startupResult = await withCodexStartupTimeout({
@@ -155,7 +133,10 @@ export async function startCodexAttemptThread(params: {
startupAbandonController.abort();
await params.onStartupTimeout();
await releaseStartupResourcesOnTimeout?.();
await abandonStartupClient();
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
},
operation: async () => {
const threadConfig = mergeCodexThreadConfigs(
@@ -172,9 +153,8 @@ export async function startCodexAttemptThread(params: {
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
const computerUseMcpElicitationDelegationRequired =
params.computerUseConfig.enabled === true;
mcpElicitationDelegationRequired =
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
const mcpElicitationDelegationRequired =
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
const enabledPluginConfigKeys = resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
@@ -182,48 +162,55 @@ export async function startCodexAttemptThread(params: {
.map((plugin) => plugin.configKey)
.toSorted()
: undefined;
const pluginAppServer = mcpElicitationDelegationRequired
pluginAppServer = mcpElicitationDelegationRequired
? {
...params.appServer,
approvalPolicy: withMcpElicitationsApprovalPolicy(params.appServer.approvalPolicy),
}
: params.appServer;
let attemptedClientAbandoned = false;
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
let startupClientLease: CodexAppServerClientLease | undefined;
let clientWorkStarted = false;
attemptedClientAbandoned = false;
let startupClientLease: (() => void) | undefined;
let startupClient: CodexAppServerClient | undefined;
let startupAttemptError: unknown;
let startupAttemptSucceeded = false;
try {
startupClientLease = await (
params.clientLeaseFactory ?? leaseSharedCodexAppServerClient
)({
startOptions: params.appServer.start,
authProfileId: params.startupAuthProfileId,
agentDir: params.agentDir,
config: params.config,
preparedAuth: {
profileId: params.startupAuthProfileId,
cacheKey: params.startupAuthAccountCacheKey ?? params.startupEnvApiKeyCacheKey,
startupClient = await params.attemptClientFactory(
params.appServer.start,
params.startupAuthProfileId,
params.agentDir,
params.config,
{
onStartedClient: (client) => {
// Timeout cleanup may fire before the client factory resolves;
// close any late-arriving client instead of leaking a lease.
startupClientForAbandonedRequestCleanup = client;
if (startupAbandoned || startupAbandonController.signal.aborted) {
void closeCodexStartupClientBestEffort(client);
}
},
abandonSignal: startupAbandonController.signal,
},
abandonSignal: startupAbandonController.signal,
});
const activeStartupLease = startupClientLease;
const activeStartupClient = activeStartupLease.client;
sharedClientLease = startupClientLease;
);
const activeStartupClient = startupClient;
let startupClientLeaseReleased = false;
startupClientLease = () => {
if (startupClientLeaseReleased) {
return;
}
startupClientLeaseReleased = true;
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
};
releaseSharedClientLease = startupClientLease;
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
clientWorkStarted = true;
ensureCodexAppServerClientRuntime(activeStartupClient, {
agentDir: params.agentDir,
authProfileId: params.startupAuthProfileId,
config: params.config,
});
const turnRouter = getCodexAppServerTurnRouter(activeStartupClient);
await ensureCodexComputerUse({
client: activeStartupClient,
pluginConfig: params.pluginConfig,
@@ -290,6 +277,7 @@ export async function startCodexAttemptThread(params: {
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (
@@ -320,57 +308,9 @@ export async function startCodexAttemptThread(params: {
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
: undefined;
let startupReservation:
| { route: CodexThreadRouteReservation; release: () => void }
| undefined;
const reserveStartupThread = (threadId: string) => {
if (startupReservation) {
if (startupReservation.route.threadId !== threadId) {
throw new Error(
`codex app-server reserved ${startupReservation.route.threadId} but started ${threadId}`,
);
}
return { release: startupReservation.release };
}
const route = turnRouter.reserveThread({
threadId,
releaseOn: params.signal,
});
let releaseIntegration: (() => void) | undefined;
try {
releaseIntegration = params.onThreadReserved?.(activeStartupClient, threadId);
} catch (error) {
route.release();
throw error;
}
let released = false;
const release = () => {
if (released) {
return;
}
released = true;
if (startupReservation?.route === route) {
startupReservation = undefined;
}
route.release();
releaseIntegration?.();
};
startupReservation = { route, release };
return { release };
};
const releaseStartupResources = async () => {
startupReservation?.release();
await releaseStartupSandboxEnvironment();
};
releaseStartupResourcesOnTimeout = releaseStartupResources;
const buildThreadLifecycleParams = (
signal: AbortSignal,
options: { freshStartOnly?: boolean } = {},
) =>
const buildThreadLifecycleParams = (signal: AbortSignal) =>
({
client: activeStartupClient,
abandonClient: activeStartupLease.abandon,
bindingStore: params.bindingStore,
params: params.buildAttemptParams(),
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
@@ -392,13 +332,7 @@ export async function startCodexAttemptThread(params: {
environmentSelection: startupEnvironmentSelection,
appServerRuntimeFingerprint,
contextEngineProjection: params.contextEngineProjection,
freshStartOnly: options.freshStartOnly,
expectedResumeThreadId: options.freshStartOnly
? undefined
: params.expectedResumeThreadId,
signal,
reserveResumeThread: options.freshStartOnly ? undefined : reserveStartupThread,
startupTokenGuard: params.startupTokenGuard,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
@@ -422,65 +356,57 @@ export async function startCodexAttemptThread(params: {
const startupThread = await startOrResumeThread(
buildThreadLifecycleParams(startupAbandonController.signal),
);
try {
reserveStartupThread(startupThread.threadId);
} catch (error) {
const unsubscribed = await unsubscribeCodexThreadBestEffort(activeStartupClient, {
threadId: startupThread.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
if (!unsubscribed) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex startup subscription cleanup failed",
{ cause: error },
);
}
throw error;
}
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (!startupReservation) {
throw new Error("codex app-server startup did not reserve its thread route");
}
startupSandboxEnvironmentAcquired = false;
startupAttemptSucceeded = true;
return {
turnRouter,
turnRoute: startupReservation.route,
client: activeStartupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
restartContextEngineCodexThread: () =>
startOrResumeThread(
buildThreadLifecycleParams(params.signal, { freshStartOnly: true }),
),
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
};
} catch (error) {
await releaseStartupResources();
await releaseStartupSandboxEnvironment();
throw error;
} finally {
if (releaseStartupResourcesOnTimeout === releaseStartupResources) {
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
releaseStartupResourcesOnTimeout = undefined;
}
}
} catch (error) {
if (sharedClientLease === startupClientLease) {
sharedClientLease = undefined;
}
const shouldAbandonStartupClient =
clientWorkStarted &&
(startupAbandoned ||
params.signal.aborted ||
isIndeterminateCodexStartupFailure(error));
if (shouldAbandonStartupClient) {
attemptedClientAbandoned = true;
await startupClientLease?.abandon();
} else {
startupClientLease?.release();
}
startupAttemptError = error;
throw error;
} finally {
if (!startupAttemptSucceeded) {
if (releaseSharedClientLease === startupClientLease) {
releaseSharedClientLease = undefined;
}
startupClientLease?.();
if (startupAbandoned || params.signal.aborted) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeCodexStartupClientBestEffort(startupClient);
} else if (
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
shouldClearSharedClientAfterStartupFailure({
error: startupAttemptError,
spawnedBy: params.spawnedBy,
})
) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeCodexStartupClientBestEffort(startupClient);
}
}
}
};
@@ -495,13 +421,18 @@ export async function startCodexAttemptThread(params: {
if (params.signal.aborted || !isCodexAppServerConnectionClosedError(error)) {
throw error;
}
const failedClient = attemptedClient;
const clearedSharedClient = clearSharedCodexAppServerClientIfCurrent(failedClient);
if (startupClientForAbandonedRequestCleanup === failedClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
embeddedAgentLog.warn(
"codex app-server connection closed during startup; retries exhausted",
{
attempt,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -513,7 +444,7 @@ export async function startCodexAttemptThread(params: {
attempt,
nextAttempt: attempt + 1,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -522,21 +453,32 @@ export async function startCodexAttemptThread(params: {
throw new Error("codex app-server startup retry loop exited unexpectedly");
},
});
const completedSharedClientLease = sharedClientLease;
if (!completedSharedClientLease) {
startupClientForAbandonedRequestCleanup = undefined;
if (!releaseSharedClientLease) {
throw new Error("codex app-server startup succeeded without a shared client lease");
}
sharedClientLease = undefined;
return {
...startupResult,
mcpElicitationDelegationRequired,
clientLease: completedSharedClientLease,
pluginAppServer,
releaseSharedClientLease,
};
} catch (error) {
const shouldAbandonStartupClient =
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
if (shouldAbandonStartupClient) {
await abandonStartupClient();
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
} else if (
shouldClearSharedClientAfterStartupRace(error) ||
shouldClearSharedClientAfterStartupFailure({
error,
spawnedBy: params.spawnedBy,
})
) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
}
throw error;
} finally {
@@ -544,13 +486,30 @@ export async function startCodexAttemptThread(params: {
}
}
function isIndeterminateCodexStartupFailure(error: unknown): boolean {
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
isCodexAppServerUnsafeSubscriptionError(error) ||
isCodexAppServerConnectionClosedError(error) ||
(error instanceof Error &&
(error.message.endsWith(" timed out") ||
error.message.endsWith(" aborted") ||
error.message.includes("write EPIPE")))
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
);
}
function shouldClearSharedClientAfterStartupFailure(params: {
error: unknown;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): boolean {
if (!(params.error instanceof Error)) {
return !params.spawnedBy;
}
if (params.error.message.includes("write EPIPE")) {
return true;
}
return !params.spawnedBy;
}

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