Compare commits

..

291 Commits

Author SHA1 Message Date
Peter Steinberger
7641ec8ba7 fix(codex): restore runtime policy metadata 2026-06-20 22:31:28 -07:00
Peter Steinberger
28cbeaaa80 fix(codex): repair startup and side-question blockers 2026-06-20 20:47:55 -07:00
Peter Steinberger
e773981088 fix(codex): preserve sidecar migration agent owner 2026-06-20 20:31:06 -07:00
Peter Steinberger
9d4d07175d fix(codex): restore side-question native policy 2026-06-20 20:12:39 -07:00
Peter Steinberger
cdb7e64994 fix(codex): restore web search provider import 2026-06-20 20:04:52 -07:00
Peter Steinberger
7daba184b5 fix(codex): format provider capability lease test 2026-06-20 19:51:24 -07:00
Peter Steinberger
3d1935dcc1 refactor(codex): simplify native context ownership 2026-06-20 19:50:40 -07:00
Peter Steinberger
b832153d0a refactor(agents): isolate native hook provider policy 2026-06-20 19:50:40 -07:00
Peter Steinberger
9bd2b4b34b test(codex): type detached delivery fixture 2026-06-20 19:50:40 -07:00
Peter Steinberger
02abd3adb5 fix(codex): fence stale completion recovery 2026-06-20 19:50:40 -07:00
Peter Steinberger
0dda56e0f6 fix(codex): serialize detached completion delivery 2026-06-20 19:50:40 -07:00
Peter Steinberger
365d78605d fix(codex): preserve clients after terminal turn failures 2026-06-20 19:50:40 -07:00
Peter Steinberger
a2c64b08ff docs(codex): clarify subagent recovery owner 2026-06-20 19:50:39 -07:00
Peter Steinberger
90decc657d test(codex): narrow monitor fixture errors 2026-06-20 19:50:39 -07:00
Peter Steinberger
e670de672d test(codex): update generation reclaim fixture 2026-06-20 19:50:39 -07:00
Peter Steinberger
c4aba64b58 fix(codex): close runtime ownership races 2026-06-20 19:50:39 -07:00
Peter Steinberger
3a024f6a8d fix(codex): finalize runtime integration 2026-06-20 19:50:39 -07:00
Peter Steinberger
c866b087eb refactor(codex): remove stale binding lease type 2026-06-20 19:50:39 -07:00
Peter Steinberger
620cca1d7f fix(codex): resolve diagnostics sessions by agent 2026-06-20 19:50:39 -07:00
Peter Steinberger
353012b8a8 fix(codex): keep media runtime inside plugin package 2026-06-20 19:50:39 -07:00
Peter Steinberger
a37c6c935a refactor(codex): unify app-server runtime ownership 2026-06-20 19:50:38 -07:00
Vincent Koc
97b97a9999 chore(deadcode): drop unused private exports 2026-06-21 10:21:58 +08:00
Vincent Koc
cbbb466852 test(scripts): route docs i18n module changes 2026-06-21 04:08:30 +02:00
Vincent Koc
c2de9d0822 test(scripts): route docs i18n and k8s metadata 2026-06-21 04:02:14 +02:00
Vincent Koc
e46aaead2c chore(deadcode): drop duplicate script declarations 2026-06-21 09:57:10 +08:00
Vincent Koc
c191d7978b test(scripts): route script fixture metadata 2026-06-21 03:48:20 +02:00
Vincent Koc
1b5e1e2d53 test(scripts): route scripts lib metadata changes 2026-06-21 03:41:44 +02:00
Vincent Koc
ab41a311cf chore(deadcode): drop duplicate pnpm runner declaration 2026-06-21 09:40:02 +08:00
Vincent Koc
9e70d251b0 test(scripts): focus plugin sdk entry metadata routing 2026-06-21 03:32:25 +02:00
Vincent Koc
025f8fb087 test(scripts): focus plugin sdk metadata routing 2026-06-21 03:23:54 +02:00
Vincent Koc
e012f2cd3c chore(deadcode): share telegram send runtime loader 2026-06-21 09:12:41 +08:00
ZengWen-DT
73c988a9c8 fix(sessions): reset stale per-channel origin fields on channel switch (#95328)
Merged via squash.

Prepared head SHA: 3a946cb078
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 18:09:37 -07:00
Vincent Koc
8b4eedf1bc test(scripts): focus extension package routing 2026-06-21 02:54:40 +02:00
Vincent Koc
b43eedbb18 chore(deadcode): drop duplicate provider default refs 2026-06-21 08:49:41 +08:00
Vincent Koc
ebbf506a77 test(scripts): focus deprecated config routing 2026-06-21 02:47:58 +02:00
Vincent Koc
e0c1add79a test(scripts): focus config guard routing 2026-06-21 02:39:11 +02:00
Vincent Koc
eae1e8f3f8 test(scripts): focus guard inventory routing 2026-06-21 02:26:34 +02:00
Vincent Koc
ba34052a0e chore(deadcode): share memory vector blob encoding 2026-06-21 08:23:16 +08:00
Vincent Koc
ce78ac3efb test(scripts): focus extension classifier routing 2026-06-21 02:10:59 +02:00
Vincent Koc
6c2c43d63f chore(deadcode): reuse gateway json responder 2026-06-21 08:06:38 +08:00
Vincent Koc
0168ff0c2e test(scripts): focus ts topology routing 2026-06-21 01:57:14 +02:00
Vincent Koc
22bdda2555 chore(deadcode): share nostr state account ids 2026-06-21 07:50:19 +08:00
Vincent Koc
2755112353 test(scripts): focus docker plan routing 2026-06-21 01:48:16 +02:00
Vincent Koc
57c7fa22bb test(scripts): focus workspace bootstrap routing 2026-06-21 01:36:00 +02:00
Vincent Koc
269e44e164 test(scripts): focus extension vitest routing 2026-06-21 01:28:05 +02:00
Vincent Koc
2e75d925ad chore(deadcode): share telegram state account ids 2026-06-21 07:22:52 +08:00
Vincent Koc
0e763c1499 test(scripts): focus changed extension routing 2026-06-21 01:21:24 +02:00
Vincent Koc
6ed9fb8ec2 test(scripts): focus direct run routing 2026-06-21 01:15:59 +02:00
Vincent Koc
a826d6a4a4 test(scripts): focus bundled build entry routing 2026-06-21 01:06:10 +02:00
Vincent Koc
9d519c1481 test(scripts): focus mobile version routing 2026-06-21 01:06:10 +02:00
Vincent Koc
b09b35c13c chore(deadcode): share ui helper predicates 2026-06-21 07:03:53 +08:00
Vincent Koc
15f2a56590 fix(harness): preserve empty prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
e66c36df37 test(copilot): fix harness test typings 2026-06-21 07:00:11 +08:00
Vincent Koc
42c504b8b1 fix(plugin-sdk): lazy-load harness agent-end effects 2026-06-21 07:00:11 +08:00
Vincent Koc
9ac3759ffc chore(plugin-sdk): refresh harness API budgets 2026-06-21 07:00:11 +08:00
Vincent Koc
d7f747af3b fix(harness): satisfy lifecycle lint gates 2026-06-21 07:00:11 +08:00
Vincent Koc
9cb3b4ea2b test(codex): prove bounded hook suffix preservation 2026-06-21 07:00:11 +08:00
Vincent Koc
f257116c92 test(codex): cover projected hook suffix bounds 2026-06-21 07:00:11 +08:00
Vincent Koc
a88ce96ee1 fix(copilot): ignore subagent idle during cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
448b3fa0be fix(copilot): retain timed-out sessions until idle 2026-06-21 07:00:11 +08:00
Vincent Koc
88ad407be2 fix(codex): retain bounded hook prompt context 2026-06-21 07:00:11 +08:00
Vincent Koc
13e77cc055 fix(copilot): normalize terminal prompt errors 2026-06-21 07:00:11 +08:00
Vincent Koc
b78718f42a fix(codex): bound delivery hint prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
54dddda68d fix(copilot): preserve replacement session reuse 2026-06-21 07:00:11 +08:00
Vincent Koc
6084442ab6 fix(harness): retain empty prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
692c7e78f4 fix(harness): bound empty hook prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
3f166b1f64 fix(codex): bound hook-expanded prompts 2026-06-21 07:00:11 +08:00
Vincent Koc
8e8905560b fix(codex): align protected prompt ranges 2026-06-21 07:00:11 +08:00
Vincent Koc
3968fea383 fix(harness): protect reset and prompt bounds 2026-06-21 07:00:11 +08:00
Vincent Koc
43e8c29fbf fix(codex): bound inbound projected context 2026-06-21 07:00:11 +08:00
Vincent Koc
7e80bb8abf fix(codex): retain projected context after hook expansion 2026-06-21 07:00:11 +08:00
Vincent Koc
9826619e22 fix(harness): pass config to agent-end side effects 2026-06-21 07:00:11 +08:00
Vincent Koc
8f3672beaa fix(copilot): settle timed-out compaction waits 2026-06-21 07:00:11 +08:00
Vincent Koc
0d25928fa4 fix(copilot): scope pending cleanup to session owner 2026-06-21 07:00:11 +08:00
Vincent Koc
807641548b fix(copilot): retain replacement session during reset 2026-06-21 07:00:11 +08:00
Vincent Koc
f87f30d429 fix(harness): preserve prompt input range 2026-06-21 07:00:11 +08:00
Vincent Koc
22c5ced69f fix(codex): preserve projected context after hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
faa4f5a23b fix(copilot): ignore stale compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
fc0b28cb73 fix(copilot): serialize compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
8265acaacb docs(copilot): use portable model example 2026-06-21 07:00:11 +08:00
Vincent Koc
d20e96a650 fix(copilot): isolate root compaction events 2026-06-21 07:00:11 +08:00
Vincent Koc
8ff1d3e67b fix(harness): surface returned tool errors 2026-06-21 07:00:11 +08:00
Vincent Koc
49a9032705 fix(copilot): preserve timeout compaction state 2026-06-21 07:00:11 +08:00
Vincent Koc
aab1dd88e0 fix(copilot): defer background compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
a81a505c72 fix(copilot): defer background compaction cleanup 2026-06-21 07:00:11 +08:00
Vincent Koc
7d219bd6e7 fix(copilot): bound background compaction waits 2026-06-21 07:00:11 +08:00
Vincent Koc
ece7d0945c fix(copilot): cancel deferred compaction on abort 2026-06-21 07:00:11 +08:00
Vincent Koc
979238dbb3 fix(copilot): retain completed compaction sessions 2026-06-21 07:00:11 +08:00
Vincent Koc
ab165d119c fix(copilot): retain timed-out compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
6f3df79f17 fix(copilot): await background compaction hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
7c2fb845db fix(harness): cover manual lifecycle hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
00f67b845b fix(harness): preserve effective hook inputs 2026-06-21 07:00:11 +08:00
Vincent Koc
6ef9207201 refactor(harness): complete lifecycle parity 2026-06-21 07:00:11 +08:00
Vincent Koc
3283540c78 chore(copilot): preserve hook bridge file modes 2026-06-21 07:00:11 +08:00
Vincent Koc
03b022b88e refactor(copilot): unify harness lifecycle hooks 2026-06-21 07:00:11 +08:00
Vincent Koc
45f9358877 chore(deadcode): share chat token formatter 2026-06-21 06:35:53 +08:00
Vincent Koc
29ec5b331c chore(deadcode): share session model default check 2026-06-21 06:33:10 +08:00
Vincent Koc
fa08942396 test(scripts): focus plugin manifest routing 2026-06-21 00:31:29 +02:00
Vincent Koc
2c9192a9a8 test(scripts): focus plugin runtime build routing 2026-06-21 00:27:26 +02:00
Vincent Koc
955f3ed094 test(scripts): focus build metadata routing 2026-06-21 00:22:35 +02:00
Vincent Koc
7217477553 chore(deadcode): reuse session terminal status helper 2026-06-21 06:16:30 +08:00
Vincent Koc
97b0b559ad test(scripts): focus static asset routing 2026-06-21 00:04:49 +02:00
Vincent Koc
1b299f4dbf chore(deadcode): share runner tool-call type guard 2026-06-21 05:59:00 +08:00
Vincent Koc
7064d198a3 test(scripts): focus tsgo guard routing 2026-06-20 23:57:23 +02:00
Vincent Koc
f18ff7551e test(scripts): focus bundled metadata routing 2026-06-20 23:53:40 +02:00
Vincent Koc
58d295840e test(scripts): focus build stamp routing 2026-06-20 23:49:30 +02:00
Vincent Koc
896b3c612d test(scripts): focus extension boundary routing 2026-06-20 23:45:20 +02:00
Vincent Koc
2f240a4a4c test(scripts): focus sdk boundary routing 2026-06-20 23:41:37 +02:00
Vincent Koc
172412d756 chore(deadcode): remove stale exported type aliases 2026-06-21 05:38:16 +08:00
Vincent Koc
58578b3250 test(scripts): focus mcp temp state routing 2026-06-20 23:32:56 +02:00
Vincent Koc
ce9769faae test(scripts): focus install package routing 2026-06-20 23:28:48 +02:00
Vincent Koc
e8920f6f6b test(scripts): focus parallels shell routing 2026-06-20 23:23:04 +02:00
Vincent Koc
9be53b4aa2 test(scripts): focus github helper routing 2026-06-20 23:15:15 +02:00
Vincent Koc
3ec2a46907 test(scripts): focus release helper routing 2026-06-20 23:11:34 +02:00
Vincent Koc
15a2d74320 test(scripts): focus installer routing changes 2026-06-20 23:05:21 +02:00
Shakker
77f07a11e7 fix: share operator approval env snapshots 2026-06-20 22:02:27 +01:00
Josh Lehman
7a0d36f3d0 refactor: add SDK transcript identity target API (#95030) 2026-06-20 14:01:07 -07:00
Vincent Koc
0a707afb9a chore(deadcode): inline exec approval wait helper 2026-06-21 04:58:14 +08:00
Shakker
bdeda6553b test: finish gateway token env routing 2026-06-20 21:50:55 +01:00
Shakker
3499b277e3 fix: route gateway env setup through helpers 2026-06-20 21:50:55 +01:00
Vincent Koc
8c8857c3ef fix(qa): keep telegram credential tests sparse safe 2026-06-20 22:45:25 +02:00
Vincent Koc
d75613e794 chore(deadcode): reuse tool result details reader 2026-06-21 04:42:48 +08:00
Shakker
beb8897f49 test: keep Claude seed HOME fallback covered 2026-06-20 21:36:15 +01:00
Shakker
add5f76a1e fix: isolate Claude history HOME setup 2026-06-20 21:34:58 +01:00
Vincent Koc
9a9f4dbefe test(rpc): map rtt measurement script changes 2026-06-20 22:32:17 +02:00
Vincent Koc
5beaaf343c test(qa): map qa e2e script changes 2026-06-20 22:29:33 +02:00
Vincent Koc
1db811282c fix(release): validate plugin manifest runner args 2026-06-20 22:23:30 +02:00
Vincent Koc
aa23d9f34e chore(deadcode): inline approval abort classification 2026-06-21 04:22:12 +08:00
Vincent Koc
2962c95010 fix(release): validate plugin runtime build args 2026-06-20 22:19:50 +02:00
Vincent Koc
80d3b132a5 fix(release): validate package dist check args 2026-06-20 22:16:26 +02:00
Shakker
1a5d84d3fe test: reuse discovery env snapshot 2026-06-20 21:09:10 +01:00
Vincent Koc
71a75b9b28 fix(release): validate package tarball check args 2026-06-20 22:08:25 +02:00
Vincent Koc
b1f562570a fix(release): validate openclaw npm verifier args 2026-06-20 22:03:38 +02:00
Vincent Koc
bdcc691745 chore(deadcode): inline message provider tool filtering 2026-06-21 04:00:09 +08:00
Shakker
4461e257e3 fix: restore env warning flags with helper 2026-06-20 20:58:13 +01:00
Vincent Koc
76014cfe95 fix(release): validate plugin npm verifier args 2026-06-20 21:57:13 +02:00
Vincent Koc
498ff1fb5a fix(release): validate plugin clawhub publish args 2026-06-20 21:53:59 +02:00
Shakker
ae81aa018d test: reuse update method env wrapper 2026-06-20 20:52:09 +01:00
Vincent Koc
1706bfda2c fix(release): validate plugin npm publish args 2026-06-20 21:51:32 +02:00
Vincent Koc
a1201e99fc fix(release): validate npm publish wrapper args 2026-06-20 21:48:01 +02:00
Shakker
90d2f161c9 fix: scope config open path env 2026-06-20 20:46:29 +01:00
Vincent Koc
bff7134a69 fix(mac): validate notarization wrapper args 2026-06-20 21:44:09 +02:00
Vincent Koc
e59d0b540e fix(mac): reject invalid codesign args 2026-06-20 21:41:34 +02:00
Shakker
aa5fcf70f7 test: share gateway credential env guard 2026-06-20 20:40:57 +01:00
Vincent Koc
63ac2e2ce0 fix(mac): reject build-and-run wrapper args 2026-06-20 21:36:42 +02:00
Shakker
803064c6e0 fix: localize session transcript env 2026-06-20 20:35:32 +01:00
Vincent Koc
577e5a4692 fix(mac): reject unknown restart options 2026-06-20 21:33:48 +02:00
Vincent Koc
a49f3f9362 fix(qa): parse qa e2e wrapper flags 2026-06-20 21:29:18 +02:00
Vincent Koc
7b9ddbda99 chore(deadcode): inline inbound prompt prefix 2026-06-21 03:27:50 +08:00
Shakker
0f83051353 test: share release journey env wrapper 2026-06-20 20:22:18 +01:00
Vincent Koc
4341cf24cc fix(crabbox): detect node-wrapped changed gates 2026-06-20 21:19:03 +02:00
Shakker
6a3f990140 fix: isolate plugin index loader env 2026-06-20 20:13:24 +01:00
scotthuang
81abc2b21b fix: preserve cron delivery awareness for target sessions (#93580)
Merged via squash.

Prepared head SHA: 460562ceff
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-20 12:13:10 -07:00
Shakker
09fcafffbc test: scope package root fallback env 2026-06-20 20:11:46 +01:00
Vincent Koc
2a93d7b9c5 chore(deadcode): inline runtime context builders 2026-06-21 03:09:43 +08:00
Shakker
0eaefc9050 fix: share npm verifier env guard 2026-06-20 20:02:45 +01:00
Shakker
52e01676be test: reuse memory fd env helper 2026-06-20 19:58:05 +01:00
Shakker
df68b81006 fix: isolate bundled probe env 2026-06-20 19:57:16 +01:00
Vincent Koc
a5417b5c6c chore(deadcode): inline bootstrap routing helpers 2026-06-21 02:55:16 +08:00
Shakker
da2c7e2d2b test: reuse startup bench env helper 2026-06-20 19:45:59 +01:00
Shakker
3a14f247ad fix: scope bundled skills env 2026-06-20 19:44:37 +01:00
Vincent Koc
5c36001fcb chore(deadcode): inline tool-search allowlist helpers 2026-06-21 02:40:32 +08:00
Shakker
05bed72a8d test: restore plugin trust env 2026-06-20 19:34:22 +01:00
Vincent Koc
c2433d41a7 fix(ci): reject release metadata option typos 2026-06-20 20:32:50 +02:00
Shakker
d368fd620c fix: restore clawhub home env 2026-06-20 19:31:26 +01:00
Vincent Koc
7dc7deaa13 fix(ci): reject mistyped changed gate options 2026-06-20 20:28:15 +02:00
Vincent Koc
a2ff59fdb2 chore(deadcode): inline same-model retry backoff 2026-06-21 02:24:56 +08:00
Vincent Koc
b12223a79f fix(qa): reject empty qa lab port flags 2026-06-20 20:17:52 +02:00
Vincent Koc
f519ceab9c fix(ci): allow gtimeout for docker pull retry 2026-06-20 20:12:30 +02:00
Vincent Koc
1f1b1aee6b chore(deadcode): remove duplicate Gemini schema helper 2026-06-21 02:09:19 +08:00
Vincent Koc
62b2e9ef14 fix(scripts): honor gtimeout in host setup wrappers 2026-06-20 20:07:50 +02:00
Vincent Koc
0f67474251 fix(docker): keep upgrade survivor auto-auth summary safe 2026-06-20 20:02:14 +02:00
Gio Della-Libera
e56fd1dc04 Keep core doctor health in contribution order (#86627)
Merged via squash.

Prepared head SHA: e0955797c1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-20 10:59:31 -07:00
Vincent Koc
b3968f69c9 fix(package): accept uppercase artifact digests 2026-06-20 19:52:59 +02:00
Vincent Koc
b0df6dc10e fix(package): scope trusted URL auth to original origin 2026-06-20 19:50:09 +02:00
Vincent Koc
141fb2b119 fix(crabbox): bootstrap macOS stdin shell scripts 2026-06-20 19:44:40 +02:00
Vincent Koc
64b6488f6c fix(crabbox): bootstrap env-option macOS stdin scripts 2026-06-20 19:39:05 +02:00
Vincent Koc
e1fc4683bb chore(deadcode): remove unused cron run log reader 2026-06-21 01:32:51 +08:00
Vincent Koc
85ab952956 fix(release): reject zero correction tags 2026-06-20 19:30:26 +02:00
Vincent Koc
abd5fb4494 fix(release): guard appcast cleanup before notes path 2026-06-20 19:28:42 +02:00
Vincent Koc
aea050b43e fix(mac): clean failed notary zip staging 2026-06-20 19:25:38 +02:00
Vincent Koc
85f552bf37 fix(qa): clean failed Parallels package locks 2026-06-20 19:20:40 +02:00
Vincent Koc
dafd98dd98 chore(deadcode): drop unused llm provider helpers 2026-06-21 01:17:06 +08:00
Vincent Koc
3632c62f85 fix(qa): isolate OTEL smoke exporter env 2026-06-20 19:14:06 +02:00
Vincent Koc
ad5d2cbc1b fix(mac): clean dSYM staging on zip failure 2026-06-20 19:07:04 +02:00
Vincent Koc
7cda58c109 fix(package): keep artifact duplicate diagnostics relative 2026-06-20 19:02:54 +02:00
Vincent Koc
5c0b99ae2b chore(deadcode): remove unused task flow retry path 2026-06-21 01:00:42 +08:00
Vincent Koc
979925c194 fix(openwebui): redact failed chat diagnostics 2026-06-20 18:58:30 +02:00
Vincent Koc
2f9f45f734 fix(telegram): include session probe artifacts 2026-06-20 18:51:20 +02:00
Vincent Koc
32cbaecd09 fix(telegram): stage full proof artifacts safely 2026-06-20 18:47:12 +02:00
Vincent Koc
1989726eb6 chore(deadcode): remove unused cron failure target wrapper 2026-06-21 00:40:26 +08:00
Vincent Koc
2454acc287 fix(crabbox): bound macos bun bootstrap fetches 2026-06-20 18:38:00 +02:00
Vincent Koc
fce5db415b fix(crabbox): bound macos node bootstrap downloads 2026-06-20 18:33:48 +02:00
Vincent Koc
2166652eb3 fix(parallels): bound update tarball probe 2026-06-20 18:28:13 +02:00
Vincent Koc
7a9c269541 chore(deadcode): drop unused cron summary guard 2026-06-21 00:27:23 +08:00
Vincent Koc
aa893b9228 fix(parallels): bound linux smoke downloads 2026-06-20 18:25:57 +02:00
Vincent Koc
98a7741468 fix(parallels): bound windows smoke downloads 2026-06-20 18:24:13 +02:00
Vincent Koc
3df4341e5a fix(parallels): bound macos smoke downloads 2026-06-20 18:20:55 +02:00
Vincent Koc
ecac665bf3 fix(parallels): pace background launch probes 2026-06-20 18:14:08 +02:00
Vincent Koc
021fd5de2b chore(deadcode): remove unused channel sender validator 2026-06-21 00:11:51 +08:00
Vincent Koc
60159b9f00 fix(parallels): keep fresh malformed package locks 2026-06-20 18:10:32 +02:00
Vincent Koc
165440117e fix(canvas): ignore stale pnpm execpath 2026-06-20 18:05:23 +02:00
Vincent Koc
fddfcbe10e fix(canvas): use corepack for a2ui pnpm fallback 2026-06-20 18:02:17 +02:00
Vincent Koc
7c850bdf38 fix(test): kill SDK package command trees 2026-06-20 17:54:16 +02:00
Vincent Koc
2bc20f2ec5 fix(test): use pnpm runner for SDK package build 2026-06-20 17:51:21 +02:00
Vincent Koc
ed500dda25 fix(qa): use corepack for lab docker build fallback 2026-06-20 17:45:09 +02:00
Vincent Koc
bc754b3160 fix(ci): restore Vitest watchdog cleanup 2026-06-20 23:42:22 +08:00
Vincent Koc
b972956173 test(ci): use public Feishu temp-dir helper 2026-06-20 23:42:22 +08:00
Vincent Koc
29444b26f2 chore(deadcode): dedupe plugin JSON logger 2026-06-20 23:37:00 +08:00
Vincent Koc
7fc5a72433 fix(qa): cap chunked credential lease payloads 2026-06-20 17:34:38 +02:00
Vincent Koc
a590f7f690 fix(qa): require boundary entry shim outputs 2026-06-20 17:25:11 +02:00
Vincent Koc
2252674168 fix(qa): reject matrix output symlink escapes 2026-06-20 17:15:45 +02:00
Vincent Koc
60612ff492 chore(deadcode): inline auto-reply display wrappers 2026-06-20 23:14:23 +08:00
Vincent Koc
c5623e72f3 fix(qa): quote generated compose paths 2026-06-20 17:08:40 +02:00
Vincent Koc
947c21ee5a refactor(qa): reuse qa shell quote helper 2026-06-20 17:05:10 +02:00
Vincent Koc
99f58ae6d6 fix(qa): quote qa docker stop command 2026-06-20 16:59:14 +02:00
Vincent Koc
3f0e740f83 chore(deadcode): inline session visibility wrappers 2026-06-20 22:56:40 +08:00
Vincent Koc
106961b513 fix(e2e): resolve mounted macOS desktop homes 2026-06-20 16:51:20 +02:00
Vincent Koc
d0001f96f0 fix(e2e): ignore bundled plugin list diagnostics 2026-06-20 16:44:11 +02:00
Vincent Koc
527bd807b9 fix(e2e): ignore runtime smoke rpc log records 2026-06-20 16:40:14 +02:00
Vincent Koc
7546231762 fix(run-node): type signal process injection 2026-06-20 22:37:26 +08:00
Vincent Koc
a977dc843d chore(deadcode): delete unused route wrappers 2026-06-20 22:37:26 +08:00
Vincent Koc
6ad7f66af2 fix(e2e): ignore inline kitchen sink json diagnostics 2026-06-20 16:34:52 +02:00
Vincent Koc
1b4fb6291d fix(e2e): parse secret proof json records 2026-06-20 16:31:09 +02:00
Vincent Koc
ee69465fe9 fix(e2e): ignore embedded diagnostic reply json 2026-06-20 16:26:00 +02:00
Vincent Koc
7b329ade32 fix(e2e): reject malformed package lock pids 2026-06-20 16:21:27 +02:00
Vincent Koc
44422b2151 fix(e2e): isolate Windows background control markers 2026-06-20 16:17:04 +02:00
Vincent Koc
48b338a5a9 fix(e2e): report signaled host server startups 2026-06-20 16:14:16 +02:00
Vincent Koc
d4f68475fd fix(e2e): preserve spaced macOS desktop homes 2026-06-20 16:11:03 +02:00
Vincent Koc
d81ae7a441 chore(deadcode): inline unused CLI helpers 2026-06-20 22:09:32 +08:00
Vincent Koc
99d8549de6 fix(crabbox): always mark shell changed gates as remote 2026-06-20 16:04:05 +02:00
Vincent Koc
7a077ffead fix(run-node): bind process signal cleanup 2026-06-20 15:55:16 +02:00
Vincent Koc
b980d678a4 fix(run-node): clean child groups on forwarded signals 2026-06-20 15:55:16 +02:00
Vincent Koc
e02e3d6971 chore(deadcode): remove unused CLI helper exports 2026-06-20 21:51:36 +08:00
Vincent Koc
6fa05685ea fix(check): clean managed child groups after forwarded signals 2026-06-20 15:46:14 +02:00
Vincent Koc
6585cb3b44 fix(watch): clean child groups on watcher shutdown 2026-06-20 15:43:04 +02:00
Vincent Koc
730c7269ef fix(test): clean Vitest runner child groups on signal 2026-06-20 15:35:33 +02:00
Vincent Koc
d72f7edf2d chore(deadcode): move gateway live probe helper out of prod path 2026-06-20 21:21:19 +08:00
Vincent Koc
24b6e6ba96 fix(test-live): force cleanup shard child groups on parent signal 2026-06-20 15:19:22 +02:00
Vincent Koc
c33f8c20ef fix(test-live): force cleanup Vitest child groups on parent signal 2026-06-20 15:16:15 +02:00
Vincent Koc
1c0c072bc2 fix(boundary): force cleanup tsc child trees on parent signal 2026-06-20 15:10:53 +02:00
Vincent Koc
aaf335af04 fix(deadcode): clean Knip child trees on parent signal 2026-06-20 15:07:19 +02:00
Vincent Koc
ad049ef083 fix(build): clean tsdown child trees on parent signal 2026-06-20 15:03:37 +02:00
Vincent Koc
6dc121eb6a chore(deadcode): move gateway auth helper out of prod path 2026-06-20 21:01:29 +08:00
Vincent Koc
0742a2f37a fix(test-report): clean parent-signaled child trees 2026-06-20 14:59:00 +02:00
Vincent Koc
e2c567538d fix(boundary): clean active check child trees 2026-06-20 14:52:00 +02:00
Vincent Koc
5c8fa5da5c chore(deadcode): move plugin test mocks out of prod paths 2026-06-20 20:41:02 +08:00
Vincent Koc
9953b85e6d fix(install-smoke): clean Bun timeout child trees 2026-06-20 14:39:39 +02:00
Vincent Koc
048014d1ab fix(memory): clean extension profiler child trees 2026-06-20 14:30:28 +02:00
Vincent Koc
0cd6975352 fix(prompt-probe): clean direct prompt child trees 2026-06-20 14:20:09 +02:00
Vincent Koc
5384b91866 fix(prompt-probe): clean gateway child trees 2026-06-20 14:09:17 +02:00
Vincent Koc
19ec9d8979 chore(deadcode): remove msteams memory test stores 2026-06-20 20:03:23 +08:00
Vincent Koc
e65619dd0c fix(crabbox): clean wrapper child trees on parent signal 2026-06-20 13:52:34 +02:00
Vincent Koc
2f0f085826 chore(deadcode): remove bedrock test injection hooks 2026-06-20 19:44:10 +08:00
Vincent Koc
0cd8db97f9 fix(bench): kill gateway child trees on windows 2026-06-20 13:30:33 +02:00
Vincent Koc
087d999fce fix(secret-providers): clean PTY configure timeout trees 2026-06-20 13:29:56 +02:00
Vincent Koc
4514b5a387 fix(runtime-smoke): kill bundled child trees on windows 2026-06-20 13:24:20 +02:00
Vincent Koc
6b82d4ecb7 chore(deadcode): remove telegram topic cache test helpers 2026-06-20 19:22:42 +08:00
Vincent Koc
f719f0cf77 fix(rpc): kill measurement gateway trees on windows 2026-06-20 13:18:02 +02:00
Vincent Koc
8ee638236a fix(secret-providers): clean command trees on parent signal 2026-06-20 13:17:14 +02:00
Vincent Koc
36934fd9f5 fix(kitchen-sink): clean command groups on parent signal 2026-06-20 13:12:00 +02:00
Vincent Koc
84895e9276 fix(docker): clean active shell groups on parent signal 2026-06-20 13:04:14 +02:00
Vincent Koc
a6e41a0cc1 fix(qa-lab): kill script timeout trees on windows 2026-06-20 12:58:52 +02:00
Vincent Koc
1ede829fbf fix(qa-lab): leave vitest timeout cleanup to wrapper 2026-06-20 12:53:39 +02:00
Vincent Koc
b93b07ee1b test(qa-lab): use temp harness in scenario runner tests 2026-06-20 12:53:39 +02:00
Vincent Koc
405e5072fd fix(qa-lab): bound test file scenario commands 2026-06-20 12:53:39 +02:00
Vincent Koc
b79dfc739c fix(gauntlet): clean measured groups on parent signal 2026-06-20 12:49:02 +02:00
Vincent Koc
ff4808f94d chore(deadcode): remove stale feishu download helpers 2026-06-20 18:47:54 +08:00
Vincent Koc
602bc0baa9 fix(bench): clean timed-out sample process groups 2026-06-20 12:31:47 +02:00
Vincent Koc
a1d278b174 fix(crabbox): preserve telegram proof kill grace 2026-06-20 12:25:03 +02:00
Vincent Koc
0fd5dae36f test(ci): allow control ui runner startup 2026-06-20 18:22:56 +08:00
Vincent Koc
984e058624 fix(e2e): reap signaled PTY command trees 2026-06-20 12:20:16 +02:00
Vincent Koc
a6e4afe0fa fix(parallels): preserve npm update stream kill grace 2026-06-20 12:15:30 +02:00
Vincent Koc
66c62d52ad chore(deadcode): remove stale msteams mention helpers 2026-06-20 18:14:41 +08:00
Vincent Koc
9e3ef487eb test(ci): cover stable closeout retries 2026-06-20 18:13:27 +08:00
Vincent Koc
739636fc33 fix(parallels): reap signaled host command groups 2026-06-20 12:08:29 +02:00
Vincent Koc
ccc1415f6d fix(ui): clean up wrapper signal descendants 2026-06-20 12:08:07 +02:00
Vincent Koc
b1608b4a4e test(ci): refresh temp-dir helper routing 2026-06-20 18:05:14 +08:00
Vincent Koc
703dfbf453 chore(deadcode): remove stale auto-reply helpers 2026-06-20 17:58:59 +08:00
Vincent Koc
7cd58cca2a fix(qa-lab): keep lifecycle probe timeout trees tracked 2026-06-20 11:58:14 +02:00
Vincent Koc
2d603c90dc fix(i18n): reap control ui process groups on signal 2026-06-20 11:54:43 +02:00
Vincent Koc
4296ecb78c fix(qa-matrix): clean up killed CLI process groups 2026-06-20 11:50:38 +02:00
Vincent Koc
fe1d981a47 fix(ci): ignore ClawSweeper self-comments 2026-06-20 17:42:47 +08:00
Vincent Koc
5cf8ba973d fix(ci): cancel superseded main workflows 2026-06-20 17:42:47 +08:00
Vincent Koc
cb394309fe fix(qa-matrix): keep timed CLI process groups tracked 2026-06-20 11:41:46 +02:00
Vincent Koc
dd29a6de52 fix(scripts): reap startup metadata help descendants 2026-06-20 11:40:00 +02:00
Vincent Koc
93a0b5d353 fix(ci): handle missing closeout assets after backoff 2026-06-20 17:39:30 +08:00
Vincent Koc
4f8fd48ea7 fix(ci): cool down main workflow fanout 2026-06-20 17:37:04 +08:00
Vincent Koc
7679872ddf chore(deadcode): drop memory shadow trial scoring shims 2026-06-20 17:33:16 +08:00
Vincent Koc
cd7385c5c6 fix(rpc): preserve gateway signal cleanup grace 2026-06-20 11:22:36 +02:00
Vincent Koc
88cf142c98 fix(qa-lab): preserve model catalog abort grace 2026-06-20 11:18:43 +02:00
773 changed files with 42847 additions and 19422 deletions

View File

@@ -4,7 +4,6 @@ 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",
@@ -619,25 +618,13 @@ function graphql(query) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
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.",
);
return githubApi(["graphql", "-f", `query=${query}`]).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|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/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|error connecting to api\.github\.com|Unexpected token '<')/i.test(
message,
)
) {
@@ -670,12 +657,8 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
}
}
for (
let index = 0;
index < commitHashes.length;
index += commitAssociationQueryBatchSize
) {
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
for (let index = 0; index < commitHashes.length; index += 40) {
const chunk = commitHashes.slice(index, index + 40);
const fields = chunk
.map(
(hash, offset) =>

View File

@@ -18,15 +18,16 @@ permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
group: ${{ github.event_name == 'push' && format('clawsweeper-dispatch-{0}-{1}', github.repository, github.ref) || format('clawsweeper-dispatch-{0}-{1}', github.repository, github.event.issue.number || github.event.pull_request.number || github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'issue_comment' ||
(github.event_name != 'issue_comment' ||
(github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) &&
!(
endsWith(github.actor, '[bot]') &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
@@ -41,6 +42,34 @@ jobs:
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Debounce main push dispatch
if: ${{ github.event_name == 'push' }}
run: sleep 45
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_api_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh api "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled ClawSweeper dispatch on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
@@ -77,6 +106,7 @@ jobs:
echo "::notice::Skipping GitHub activity dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
activity="$(jq -c \
--arg target_repo "$TARGET_REPO" \
--arg event_name "$SOURCE_EVENT" \
@@ -143,7 +173,7 @@ jobs:
' "$GITHUB_EVENT_PATH")"
payload="$(jq -nc --argjson activity "$activity" \
'{event_type:"github_activity",client_payload:{activity:$activity}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched GitHub activity to ClawSweeper."
@@ -165,6 +195,7 @@ jobs:
echo "::notice::Skipping ClawSweeper dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
@@ -173,7 +204,7 @@ jobs:
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper review."
@@ -198,6 +229,7 @@ jobs:
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$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
@@ -206,7 +238,7 @@ jobs:
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
-H "Accept: application/vnd.github+json" \
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
-f content="eyes" 2>"$err" >/dev/null; then
@@ -233,7 +265,7 @@ jobs:
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
@@ -254,7 +286,7 @@ jobs:
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
if GH_TOKEN="$DISPATCH_TOKEN" gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper comment router."
@@ -276,6 +308,7 @@ jobs:
echo "::notice::Skipping ClawSweeper commit dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
case "$CREATE_CHECKS" in
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
*) create_checks=false ;;
@@ -287,7 +320,7 @@ jobs:
--arg ref "$SOURCE_REF" \
--argjson create_checks "$create_checks" \
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper commit review."

View File

@@ -33,7 +33,7 @@ on:
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: control-ui-locale-refresh
cancel-in-progress: false
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
plan:

View File

@@ -13,6 +13,10 @@ on:
permissions:
contents: read
concurrency:
group: docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
sync-publish-repo:
runs-on: ubuntu-latest

View File

@@ -70,7 +70,7 @@ on:
default: ""
type: string
npm_telegram_package_spec:
description: Optional published package spec for the focused package Telegram E2E rerun
description: Optional published package spec for the package Telegram E2E lane
required: false
default: ""
type: string
@@ -95,7 +95,7 @@ on:
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the focused package Telegram E2E rerun
description: Provider mode for the package Telegram E2E lane
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 focused package Telegram E2E rerun
description: Optional comma-separated Telegram scenario ids for the package Telegram lane
required: false
default: ""
type: string
@@ -200,16 +200,14 @@ jobs:
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published release package: \`${RELEASE_PACKAGE_SPEC}\`"
fi
if [[ "$RERUN_GROUP" == "npm-telegram" && -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
elif [[ "$RERUN_GROUP" == "npm-telegram" && -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
elif [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${RELEASE_PACKAGE_SPEC}\`"
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"
elif [[ "$RERUN_GROUP" == "all" && "$RELEASE_PROFILE" == "full" ]]; then
echo "- Package Telegram E2E: parent \`release-package-under-test\` artifact"
else
echo "- Package Telegram E2E: skipped by rerun group"
echo "- Package Telegram E2E: skipped unless \`release_profile=full\`, \`release_package_spec\`, or \`npm_telegram_package_spec\` is provided"
fi
if [[ -n "${EVIDENCE_PACKAGE_SPEC// }" ]]; then
echo "- Private evidence package proof: \`${EVIDENCE_PACKAGE_SPEC}\`"
@@ -766,10 +764,80 @@ 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]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'npm-telegram' && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') }}
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')) }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
@@ -785,6 +853,8 @@ 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: |
@@ -813,7 +883,18 @@ jobs:
return "$status"
}
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
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
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
fi

View File

@@ -717,6 +717,7 @@ 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

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: openclaw-stable-main-closeout
cancel-in-progress: false
group: openclaw-stable-main-closeout-${{ github.event_name == 'workflow_dispatch' && (inputs.tag || github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
resolve:
@@ -43,6 +43,30 @@ jobs:
should_closeout: ${{ steps.inputs.outputs.should_closeout }}
tag: ${{ steps.inputs.outputs.tag }}
steps:
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Checkout pushed main
if: ${{ github.event_name == 'push' }}
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -62,9 +86,13 @@ jobs:
TRIGGER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "push" ]]; then
sleep 45
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
if [[ "$EVENT_NAME" == "push" ]]; then
main_ref="$TRIGGER_SHA"
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt \
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
if [[ -z "$tag" ]]; then
@@ -91,7 +119,7 @@ jobs:
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
tag_package_read=false
for attempt in 1 2 3; do
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' > "$tag_package_content"; then
tag_package_read=true
break
@@ -126,7 +154,7 @@ jobs:
closeout_checksum_asset="${closeout_asset}.sha256"
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
mkdir -p "$closeout_dir"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
gh_with_retry release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$closeout_asset" --pattern "$closeout_checksum_asset" --dir "$closeout_dir" || true
closeout_json_path="$closeout_dir/$closeout_asset"
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
@@ -182,8 +210,11 @@ jobs:
fi
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
mkdir -p "$evidence_dir"
if ! gh release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir"; then
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
evidence_path="$evidence_dir/$evidence_asset"
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
@@ -192,7 +223,6 @@ jobs:
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
exit 1
fi
evidence_path="$evidence_dir/$evidence_asset"
if ! (
cd "$evidence_dir"
sha256sum --strict --status -c "$evidence_checksum_asset"
@@ -272,6 +302,30 @@ jobs:
exit 1
fi
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Verify release workflow evidence
env:
GH_TOKEN: ${{ github.token }}
@@ -279,7 +333,8 @@ jobs:
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
run: |
set -euo pipefail
gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/full-release-validation-run.json"
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
@@ -296,7 +351,7 @@ jobs:
}
}
NODE
gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/release-publish-run.json"
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
@@ -317,7 +372,7 @@ jobs:
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
rm -rf "$manifest_dir"
mkdir -p "$manifest_dir"
gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
--dir "$manifest_dir"
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
@@ -346,7 +401,8 @@ jobs:
run: |
set -euo pipefail
mkdir -p "$CLOSEOUT_DIR"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets \
> "$CLOSEOUT_DIR/github-release.json"
node scripts/verify-stable-main-closeout.mjs \
@@ -372,21 +428,23 @@ jobs:
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
release_version="${RELEASE_TAG#v}"
attach_or_verify() {
local source_path="$1"
local asset_name="$2"
local existing_dir="$CLOSEOUT_DIR/existing-${asset_name}"
mkdir -p "$existing_dir"
if gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir"; then
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir" || true
if [[ -f "$existing_dir/$asset_name" ]]; then
cmp --silent "$source_path" "$existing_dir/$asset_name" || {
echo "Existing release asset $asset_name differs from closeout evidence." >&2
exit 1
}
return
fi
gh release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
}
attach_or_verify \
"$CLOSEOUT_DIR/stable-main-closeout.json" \

View File

@@ -38,8 +38,8 @@ on:
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -19,7 +19,7 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

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, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
- **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.
- **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 available from npm and ClawHub. (#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 intentionally npm-only because its ClawHub package name is unavailable. (#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 from npm or ClawHub. (#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 exclusively from npm. (#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, 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.
- 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.
### 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 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, 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, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- 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.
- 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, 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.
- 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.
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD history: 422 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~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
@@ -175,7 +175,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
- **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-icenter and @vincentkoc and @0pen7ech.
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt 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,53 +412,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
- **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.9
OPENCLAW_ANDROID_VERSION_CODE=2026060901
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201

View File

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

View File

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

View File

@@ -1,13 +1,5 @@
# 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.9
OPENCLAW_MARKETING_VERSION = 2026.6.9
OPENCLAW_IOS_VERSION = 2026.6.2
OPENCLAW_MARKETING_VERSION = 2026.6.2
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,5 +1,3 @@
Maintenance update for the current OpenClaw release.
OpenClaw is now available on iPhone.
- 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.
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.

View File

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

View File

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

View File

@@ -128,14 +128,9 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
bundledPluginFile("telegram", "src/draft-chunking.ts"),
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
bundledPluginFile("voice-call", "src/providers/index.ts"),
],
ignore: ["packages/*/dist/**"],
workspaces: {

View File

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

View File

@@ -1,2 +1,2 @@
d5a8dc906d615f081799783ffda46b48dac43c3de9ddcb7c4b95311031fbe80e plugin-sdk-api-baseline.json
47d93e8b79e5d5fd0ef0a607a831a5b205c94e759a48401ce4a34da98e42b93d plugin-sdk-api-baseline.jsonl
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl

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`. 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.
`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.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -172,10 +172,12 @@ A finding includes:
| `ocPath` | Precise `oc://` address when a check can point to one. |
| `fixHint` | Suggested operator action or repair summary. |
This release registers the modernized core doctor checks on the structured
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for bundled follow-up consumers, but plugin-backed checks only run
after their owning package registers them in the active command path.
Modernized core doctor checks stay attached to the ordered doctor contribution
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
health registry is the extension point: bundled and plugin-backed checks run
after core doctor checks once their owning package registers them in the active
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for those extension consumers.
## Check Selection

View File

@@ -143,12 +143,39 @@ 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 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.
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.
```json5
{

View File

@@ -79,9 +79,9 @@ Pin one model (or one provider) to the harness:
{
agents: {
defaults: {
model: "github-copilot/gpt-5.5",
model: "github-copilot/auto",
models: {
"github-copilot/gpt-5.5": {
"github-copilot/auto": {
agentRuntime: { id: "copilot" },
},
},
@@ -95,6 +95,10 @@ when only that model should be routed through the harness; set
`agentRuntime.id` on a provider when every model under that provider should
use it.
`github-copilot/auto` is the portable starting point. Named Copilot models are
account- and organization-policy-dependent, so only pin one after confirming
that the authenticated Copilot CLI exposes it.
## Supported providers
The harness advertises support for the canonical `github-copilot` provider
@@ -169,8 +173,9 @@ The harness reads its config from per-attempt input
- `infiniteSessionConfig` — optional override for the SDK
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
leave as-is.
- `hooksConfig` — optional bridge config exposing OpenClaw
before/after-message-write hooks to the SDK loop.
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
config for tool/MCP, user-prompt, session, and error callbacks.
It is separate from OpenClaw's portable lifecycle hooks.
- `permissionPolicy` — optional override for the SDK's
`onPermissionRequest` handler used for built-in SDK tool kinds
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
@@ -181,6 +186,14 @@ The harness reads its config from per-attempt input
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
- `enableSessionTelemetry` — optional SDK session telemetry flag.
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
harness runs `before_prompt_build` (and the legacy `before_agent_start`
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
standard harness helpers. Successful SDK compactions also run
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
native SDK-only callbacks that have no portable equivalent.
Nothing in the rest of OpenClaw needs to know about these fields. Other
plugins, channels, and core code only see the standard
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.

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; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm. 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; ClawHub: `clawhub:@openclaw/stepfun-provider`
- Install route: npm
## Surface

View File

@@ -185,6 +185,17 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
leaves prompt errors, in-flight turns, and intentional silent replies such as
`NO_REPLY` unclassified.
### Agent-end side effects
Native harnesses must call `runAgentEndSideEffects(...)` from
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
dispatches the portable `agent_end` hook and OpenClaw's research capture without
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
non-interactive runs where the attempt must not resolve until those side effects
finish. Both helpers accept the same `{ event, ctx }` payload as
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
attempt result.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
</Accordion>
<Accordion title="api.runtime.agent.defaults">

View File

@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
| `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-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 |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

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. Package
Acceptance provides the canonical package Telegram E2E during candidate
validation, avoiding a second concurrent live poller.
`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.
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,16 +460,20 @@ gh workflow run full-release-validation.yml \
```
The workflow resolves the target ref, dispatches manual `CI` with
`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
`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
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
@@ -554,8 +558,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 use the canonical package Telegram
E2E inside Package Acceptance. Focused
`npm_telegram_package_spec`; full/all runs with `release_profile=full` use the
release-checks package artifact. 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,7 +53,8 @@ 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 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`. |
| 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`. |
| 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.
@@ -75,7 +76,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, 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`. |
| 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`. |
| 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`. |
@@ -106,9 +107,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`. Package Acceptance supplies the canonical package
Telegram E2E for every full candidate, so the umbrella does not duplicate that
live poller.
`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.
| Profile | Intended use | Included live/provider coverage |
| --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -188,7 +189,7 @@ workflow first, then rerun the smallest matching handle above.
Useful artifacts:
- `release-package-under-test` from `OpenClaw Release Checks`
- `release-package-under-test` from the Full Release Validation parent and `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

@@ -1,12 +1,12 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.9",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/acpx",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9",
"version": "2026.6.8",
"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.9"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.9",
"openclawVersion": "2026.6.8",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9",
"version": "2026.6.8",
"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.9"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.9",
"openclawVersion": "2026.6.8",
"bundledDist": false
},
"release": {

View File

@@ -6,8 +6,6 @@ type SharedIniFileLoader = {
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
};
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
}
@@ -21,12 +19,6 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
}
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
if (sharedIniFileLoaderForTest !== undefined) {
if (!sharedIniFileLoaderForTest) {
throw new Error("AWS shared INI file loader unavailable");
}
return sharedIniFileLoaderForTest;
}
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
}
@@ -40,10 +32,3 @@ export async function refreshAwsSharedConfigCacheForBedrock(
const loader = await loadSharedIniFileLoader();
await loader.loadSharedConfigFiles({ ignoreCache: true });
}
/** Override the shared INI loader for Bedrock credential-refresh tests. */
export function setAwsSharedIniFileLoaderForTest(
loader: SharedIniFileLoader | null | undefined,
): void {
sharedIniFileLoaderForTest = loader;
}

View File

@@ -9,14 +9,9 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
import amazonBedrockPlugin from "./index.js";
import {
resetBedrockAppProfileCacheEligibilityForTest,
setBedrockAppProfileControlPlaneForTest,
} from "./register.sync.runtime.js";
type BedrockClientResult =
| {
@@ -96,6 +91,10 @@ vi.mock("@aws-sdk/client-bedrock", () => {
};
});
vi.mock("@smithy/shared-ini-file-loader", () => ({
loadSharedConfigFiles: refreshSharedConfigCache,
}));
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
@@ -149,6 +148,8 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
const APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
const OPUS_APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
@@ -267,26 +268,12 @@ describe("amazon-bedrock provider plugin", () => {
inferenceProfileGetResults.length = 0;
bedrockClientConfigs.length = 0;
refreshSharedConfigCache.mockClear();
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
sendBedrockCommand.mockClear();
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
setBedrockAppProfileControlPlaneForTest((region) => ({
async getInferenceProfile(input) {
class GetInferenceProfileCommand {
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
}
bedrockClientConfigs.push(region ? { region } : {});
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
},
}));
});
afterEach(() => {
setBedrockAppProfileControlPlaneForTest(undefined);
setAwsSharedIniFileLoaderForTest(undefined);
resetBedrockDiscoveryCacheForTest();
resetBedrockAppProfileCacheEligibilityForTest();
});
afterAll(() => {
@@ -1501,8 +1488,8 @@ describe("amazon-bedrock provider plugin", () => {
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
OPUS_APP_INFERENCE_PROFILE_ARN,
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
payload,
);

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.9",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9",
"version": "2026.6.8",
"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.9"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.9",
"openclawVersion": "2026.6.8",
"bundledDist": false
},
"release": {

View File

@@ -254,27 +254,7 @@ type BedrockControlPlane = {
}) => Promise<BedrockGetInferenceProfileResponse>;
};
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
/** Reset app-profile prompt-cache eligibility state for tests. */
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
appProfileTraitsCache.clear();
}
/** Override Bedrock app-profile control-plane checks for tests. */
export function setBedrockAppProfileControlPlaneForTest(
controlPlane: BedrockControlPlaneFactory | undefined,
): void {
bedrockControlPlaneOverride = controlPlane;
resetBedrockAppProfileCacheEligibilityForTest();
}
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
if (bedrockControlPlaneOverride) {
return bedrockControlPlaneOverride(region);
}
await refreshAwsSharedConfigCacheForBedrock();
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const client = new BedrockClient(region ? { region } : {});

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.9",
"openclawVersion": "2026.6.8",
"bundledDist": false
},
"release": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
export interface PnpmRunnerParams {
comSpec?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
nodeArgs?: string[];
nodeExecPath?: string;
npmExecPath?: string;

View File

@@ -2,6 +2,7 @@
* Cross-platform pnpm command resolver used by Canvas build scripts.
*/
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
import path from "node:path";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
}
}
function isFile(value) {
try {
return statSync(value).isFile();
} catch {
return false;
}
}
function resolvePathEnvKey(env) {
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
}
function findExecutableOnPath(command, envPath, platform, env, cwd) {
if (typeof envPath !== "string" || envPath.length === 0) {
return undefined;
}
const extensions =
platform === "win32"
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((extension) => extension.toLowerCase())
: [""];
const pathImpl = platform === "win32" ? path.win32 : path;
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
for (const directory of envPath.split(pathDelimiter)) {
if (!directory) {
continue;
}
const resolvedDirectory = pathImpl.isAbsolute(directory)
? directory
: pathImpl.resolve(cwd, directory);
for (const extension of extensions) {
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
return candidate;
}
}
}
return undefined;
}
function isNodeRunnablePnpmExecPath(value) {
if (!isPnpmExecPath(value)) {
return false;
}
const { extension } = inspectExecutablePath(value);
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
return true;
return isFile(value);
}
if (extension.length > 0) {
return false;
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
const pnpmArgs = params.pnpmArgs ?? [];
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
const cwd = params.cwd ?? process.cwd();
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
if (pnpmPath) {
return platform === "win32"
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args: pnpmArgs, command: pnpmPath, shell: false };
}
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
if (corepackPath) {
const args = ["pnpm", ...pnpmArgs];
return platform === "win32"
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
: { args, command: corepackPath, shell: false };
}
if (platform === "win32") {
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
}

View File

@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
try {
expect(
resolvePnpmRunner({
env: { PATH: "" },
npmExecPath,
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["pnpm", "exec", "rolldown", "-c"],
command: corepackPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
posixIt("prefers a direct pnpm executable over Corepack", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
const pnpmPath = path.join(tempDir, "pnpm");
const corepackPath = path.join(tempDir, "corepack");
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
chmodSync(pnpmPath, 0o755);
chmodSync(corepackPath, 0o755);
try {
expect(
resolvePnpmRunner({
env: { PATH: tempDir },
npmExecPath: "",
platform: "darwin",
pnpmArgs: ["exec", "rolldown", "-c"],
}),
).toEqual({
args: ["exec", "rolldown", "-c"],
command: pnpmPath,
shell: false,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { loginChutes } from "./oauth.js";
function boundedErrorResponse(
body: string,
status = 500,
): {
function boundedErrorResponse(body: string, status = 500): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,18 @@
// 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();
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
it("supports the canonical codex virtual provider", () => {
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
@@ -40,8 +49,149 @@ describe("Codex agent harness supports()", () => {
});
it("honors explicit provider id overrides", () => {
const narrowHarness = createCodexAppServerAgentHarness({ providerIds: ["codex"] });
const narrowHarness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
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,11 +7,13 @@ 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 = [
@@ -37,12 +39,14 @@ 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) =>
@@ -71,6 +75,7 @@ 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 },
});
@@ -78,6 +83,7 @@ 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 },
});
@@ -85,20 +91,43 @@ 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.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
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`,
);
}
}
},
dispose: async () => {

View File

@@ -4,10 +4,30 @@ 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,
}));
@@ -40,7 +60,6 @@ describe("codex plugin", () => {
const registerProvider = vi.fn();
const registerWebSearchProvider = vi.fn();
const on = vi.fn();
const onConversationBindingResolved = vi.fn();
plugin.register(
createTestPluginApi({
@@ -49,7 +68,7 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
runtime: createCodexTestRuntime(),
registerAgentHarness,
registerCommand,
registerMediaUnderstandingProvider,
@@ -57,7 +76,6 @@ describe("codex plugin", () => {
registerProvider,
registerWebSearchProvider,
on,
onConversationBindingResolved,
}),
);
@@ -67,9 +85,6 @@ 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");
@@ -103,33 +118,12 @@ 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();
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
@@ -150,8 +144,196 @@ 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({ pluginConfig: { appServer: {} } });
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const result = { success: true };
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
@@ -160,6 +342,7 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "hello" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},
@@ -194,11 +377,7 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: { codexPlugins: { enabled: false } },
runtime: {
config: {
current: () => liveConfig,
},
} as never,
runtime: createCodexTestRuntime(() => liveConfig),
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
@@ -218,14 +397,49 @@ 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({ pluginConfig: { appServer: {} } });
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const runSideQuestion = harness["runSideQuestion"];
const result = { text: "ok" };
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
@@ -238,6 +452,7 @@ describe("codex plugin", () => {
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
{ question: "btw" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},

View File

@@ -4,48 +4,72 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
} 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,
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
} from "./src/node-cli-session-registration.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 resolveCurrentConfig = () =>
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
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 resolveCurrentPluginConfig = () =>
// Codex plugin config can change at runtime; resolve from live config for
// harness attempts and binding claims instead of keeping startup values.
resolveLivePluginConfigObject(
resolveCurrentConfig,
runtimeConfigLoader,
"codex",
api.pluginConfig as Record<string, unknown>,
) ?? api.pluginConfig;
);
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.registerAgentHarness(
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
}),
);
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerWebSearchProvider(
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
@@ -59,43 +83,43 @@ export default definePluginEntry({
}
api.registerCommand(
createCodexCommand({
pluginConfig: api.pluginConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
deps: {
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
bindingStore,
listCodexCliSessionsOnNode: async (params) =>
await (
await loadNodeCliSessions()
).listCodexCliSessionsOnNode({
runtime: api.runtime,
...params,
}),
resolveCodexCliSessionForBindingOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resolveCodexCliSessionForBindingOnNode({
runtime: api.runtime,
...params,
}),
codexPluginsManagementIo: {
readConfig: () => {
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
const plugins = (current as Record<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
if (
!codexPlugins ||
typeof codexPlugins !== "object" ||
Array.isArray(codexPlugins)
) {
return Promise.resolve({});
}
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;
const block = codexPlugins as Record<string, unknown>;
const declared = block.plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
enabled: block.enabled === true,
});
}
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
enabled: block.enabled === true,
plugins: declared as Record<string, never>,
});
},
@@ -105,17 +129,12 @@ 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>;
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>;
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 ??= {};
update(codexPlugins as CodexPluginsConfigBlock);
},
});
@@ -124,14 +143,58 @@ export default definePluginEntry({
},
}),
);
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
api.on("inbound_claim", async (event, ctx) => {
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
return await handleCodexConversationInboundClaim(event, ctx, {
bindingStore,
pluginConfig: resolveCurrentPluginConfig(),
config: resolveCurrentConfig(),
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
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 } : {}),
}),
);
});
},
});

View File

@@ -2,8 +2,25 @@
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 type { CodexAppServerClient } from "./src/app-server/client.js";
import { CodexAppServerRpcError, 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(),
@@ -85,13 +102,15 @@ 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 requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
const closeHandlers = new Set<() => void>();
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") {
@@ -104,51 +123,60 @@ function createFakeClient(options?: {
return threadStartResult();
}
if (method === "turn/start") {
if (options?.approvalRequestMethod) {
for (const handler of requestHandlers) {
const response = handler({ method: options.approvalRequestMethod });
if (response !== undefined) {
approvalResponses.push(response);
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" },
});
}
}
return turnStartResult();
}
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,
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,
},
willRetry: false,
},
});
});
}
} 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,
},
});
}
}
} 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,
},
});
}
}
};
emitTurnNotifications();
return turnStartResult(
options?.completeWithItems ? "completed" : "inProgress",
options?.completeWithItems
@@ -164,6 +192,12 @@ function createFakeClient(options?: {
: [],
);
}
if (method === "turn/interrupt" && options?.interruptError) {
throw options.interruptError;
}
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
throw options.unsubscribeError;
}
return {};
});
@@ -173,14 +207,17 @@ function createFakeClient(options?: {
notifications.add(handler);
return () => notifications.delete(handler);
},
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
addRequestHandler() {
return () => undefined;
},
addCloseHandler(handler: () => void) {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests, approvalResponses };
return { client, requests };
}
describe("codex media understanding provider", () => {
@@ -192,11 +229,9 @@ 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 (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({
clientFactory,
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
const cfg = {
auth: {
@@ -219,42 +254,33 @@ 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,
{ timeoutMs: 30_000 },
expect.objectContaining({ 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",
approvalPolicy: "on-request",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
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: {
"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",
},
config: EXPECTED_MEDIA_THREAD_CONFIG,
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
expect(requests[2]?.params).toEqual({
threadId: "thread-1",
@@ -262,9 +288,6 @@ 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",
});
});
@@ -272,8 +295,12 @@ 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({ clientFactory });
const cfg = {};
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
const cfg = {
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -286,11 +313,16 @@ describe("codex media understanding provider", () => {
agentDir: " ",
});
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() }));
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" }),
);
});
it("preserves configured WebSocket transport for media turns", async () => {
@@ -370,7 +402,7 @@ describe("codex media understanding provider", () => {
try {
const { client } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const result = await provider.describeImage?.({
@@ -393,33 +425,97 @@ describe("codex media understanding provider", () => {
}
});
it("declines approval requests during image understanding", async () => {
const { client, approvalResponses } = createFakeClient({
approvalRequestMethod: "item/permissions/requestApproval",
});
it("starts the media deadline before client acquisition", async () => {
vi.useFakeTimers();
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(
async () => await new Promise<CodexAppServerClient>(() => {}),
),
});
await provider.describeImage?.({
const description = 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,
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",
});
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
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 = {
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",
};
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);
});
it("extracts text from terminal turn items", async () => {
const { client } = createFakeClient({ completeWithItems: true });
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const result = await provider.describeImages?.({
@@ -438,7 +534,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({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
await expect(
@@ -459,7 +555,7 @@ describe("codex media understanding provider", () => {
it("surfaces Codex app-server turn errors", async () => {
const { client } = createFakeClient({ notifyError: "vision unavailable" });
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
await expect(
@@ -476,12 +572,107 @@ 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({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const result = await provider.extractStructured?.({
@@ -522,31 +713,21 @@ 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",
approvalPolicy: "on-request",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
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: {
"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",
},
config: EXPECTED_MEDIA_THREAD_CONFIG,
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
const turnParams = requests[2]?.params as
| {
@@ -559,9 +740,9 @@ describe("codex media understanding provider", () => {
}
| undefined;
expect(turnParams?.threadId).toBe("thread-1");
expect(turnParams?.approvalPolicy).toBe("on-request");
expect(turnParams?.model).toBe("gpt-5.4");
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
expect(turnParams?.approvalPolicy).toBeUndefined();
expect(turnParams?.model).toBeUndefined();
expect(turnParams?.cwd).toBeUndefined();
expect(turnParams?.effort).toBe("low");
expect(turnParams?.input).toHaveLength(3);
expect(turnParams?.input?.[0]?.type).toBe("text");
@@ -584,7 +765,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":"only text"}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
await expect(
@@ -604,7 +785,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({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
await expect(
@@ -633,7 +814,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":123,"tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
await expect(

View File

@@ -1,216 +1,35 @@
/**
* 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";
/** Lazy registration facade for Codex-backed media understanding. */
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
import {
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
} from "./src/app-server/bounded-turn.js";
import type { CodexUserInput } from "./src/app-server/protocol.js";
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.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.";
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
/** Dependencies and plugin config for Codex media-understanding calls. */
export type CodexMediaUnderstandingProviderOptions = {
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
/**
* Builds the media-understanding provider that delegates image tasks to an
* isolated Codex app-server session.
*/
/** Builds a provider whose app-server implementation loads on first use. */
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 (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),
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),
};
}
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.9",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.6.9",
"version": "2026.6.8",
"dependencies": {
"@openai/codex": "0.139.0",
"typebox": "1.1.39",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.6.9",
"version": "2026.6.8",
"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.9"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.9"
"openclawVersion": "2026.6.8"
},
"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 { listCodexAppServerModels } from "./src/app-server/models.js";
import type { listAllCodexAppServerModels } from "./src/app-server/models.js";
import {
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
leaseSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
@@ -26,7 +26,8 @@ function createFakeCodexClient(): CodexAppServerClient {
return {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
@@ -39,7 +40,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
};
async function listTestCodexAppServerModels(
options: Parameters<typeof listCodexAppServerModels>[0] = {},
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
) {
expect(options.sharedClient).toBe(false);
const client = await createIsolatedCodexAppServerClient({
@@ -183,45 +184,33 @@ describe("codex provider", () => {
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
});
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: [],
},
],
});
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: [],
},
],
}));
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,
});
@@ -277,7 +266,7 @@ describe("codex provider", () => {
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await getSharedCodexAppServerClient({
await leaseSharedCodexAppServerClient({
startOptions: {
transport: "stdio",
command: "/tmp/openclaw-test-codex",

View File

@@ -18,16 +18,11 @@ import {
CODEX_PROVIDER_ID,
FALLBACK_CODEX_MODELS,
} from "./provider-catalog.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
import type { CodexAppServerStartOptions } 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";
@@ -39,7 +34,6 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
@@ -123,6 +117,11 @@ 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,
@@ -156,13 +155,15 @@ 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 ?? listCodexAppServerModelsLazy,
listModels: options.listModels ?? listAllCodexAppServerModelsLazy,
timeoutMs,
startOptions: appServer.start,
onDiscoveryFailure: options.onDiscoveryFailure,
@@ -200,22 +201,14 @@ async function listModelsBestEffort(params: {
onDiscoveryFailure?: (error: unknown) => void;
}): Promise<CodexAppServerModel[]> {
try {
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;
// 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);
} catch (error) {
params.onDiscoveryFailure?.(error);
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
@@ -225,15 +218,14 @@ async function listModelsBestEffort(params: {
}
}
async function listCodexAppServerModelsLazy(options: {
async function listAllCodexAppServerModelsLazy(options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}): Promise<CodexAppServerModelListResult> {
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
return listCodexAppServerModels(options);
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
return listAllCodexAppServerModels(options);
}
async function requestCodexAppServerRateLimitsLazy(options: {

View File

@@ -1,9 +1,6 @@
// Codex tests cover app server policy plugin behavior.
import { describe, expect, it } from "vitest";
import {
resolveCodexAppServerForModelProvider,
resolveCodexAppServerForOpenClawToolPolicy,
} from "./app-server-policy.js";
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
describe("Codex app-server policy", () => {
@@ -69,143 +66,4 @@ 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,11 +2,10 @@
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
* approvals.
*/
import {
canUseCodexModelBackedApprovalsReviewerForModel,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type OpenClawExecPolicyForCodexAppServer,
import type {
CodexAppServerRuntimeOptions,
CodexPluginConfig,
OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
/**
@@ -45,35 +44,6 @@ 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";
}
@@ -83,12 +53,3 @@ 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,8 +285,7 @@ function matchesCurrentTurn(
if (!requestParams) {
return false;
}
const requestThreadId =
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
const requestThreadId = readString(requestParams, "threadId");
const requestTurnId = readString(requestParams, "turnId");
return requestThreadId === threadId && requestTurnId === turnId;
}

View File

@@ -2,10 +2,41 @@
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 () => ({}));
@@ -22,7 +53,58 @@ describe("Codex app-server attempt client cleanup", () => {
);
});
it("swallows unsubscribe cleanup failures", async () => {
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 () => {
const request = vi.fn(async () => {
throw new Error("already gone");
});
@@ -32,7 +114,7 @@ describe("Codex app-server attempt client cleanup", () => {
threadId: "thread-1",
timeoutMs: 123,
}),
).resolves.toBeUndefined();
).resolves.toBe(false);
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
@@ -40,4 +122,31 @@ 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,60 +2,124 @@
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
*/
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { CodexAppServerClient } from "./client.js";
import {
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
import type { CodexAppServerClientLease } 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;
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;
/** 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";
}
closeable.close?.();
}
export async function closeCodexStartupClientBestEffort(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
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}`,
);
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
/** 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();
}
return;
throw error;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
/** 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;
}
return;
params.onRetry?.();
if (!(await params.waitForActiveTurnCompletion())) {
throw error;
}
await params.afterActiveTurnCompletion?.();
return await params.startTurn();
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
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 },
);
}
await closeClientAndWaitIfAvailable(client);
}
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
@@ -84,28 +148,56 @@ export function interruptCodexTurnBestEffort(
}
}
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
export async function unsubscribeCodexThreadBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
timeoutMs: number;
},
): Promise<void> {
): Promise<boolean> {
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.
@@ -116,10 +208,9 @@ 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,
@@ -129,28 +220,10 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
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,
});
}
}
}
await params.abandonClientLease();
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

@@ -586,6 +586,51 @@ export function prependCodexOpenClawPromptContext(
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
}
/**
* Maps the surviving user-request portion of an input range after delivery
* metadata has been relocated before the request.
*/
export function resolveCodexDeliveryHintPreservedInputRange(params: {
prompt: string;
promptInputRange: { start: number; end: number } | undefined;
decoratedPrompt: string;
}): { start: number; end: number } | undefined {
const { prompt, promptInputRange, decoratedPrompt } = params;
const { deliveryHint, prompt: promptWithoutDeliveryHint } = splitLeadingCodexDeliveryHint(prompt);
if (
!deliveryHint ||
!promptInputRange ||
promptInputRange.start < 0 ||
promptInputRange.end < promptInputRange.start ||
promptInputRange.end > prompt.length ||
!decoratedPrompt.endsWith(promptWithoutDeliveryHint)
) {
return undefined;
}
const promptWithoutDeliveryHintStart = prompt.length - promptWithoutDeliveryHint.length;
const inputStart = Math.max(promptInputRange.start, promptWithoutDeliveryHintStart);
const inputEnd = Math.max(
inputStart,
Math.min(
promptInputRange.end,
promptWithoutDeliveryHint.length + promptWithoutDeliveryHintStart,
),
);
const decoratedPromptSuffixStart = decoratedPrompt.length - promptWithoutDeliveryHint.length;
const requestHeader = "Current user request:\n";
const requestHeaderStart = decoratedPromptSuffixStart - requestHeader.length;
// Delivery metadata moves outside the request, so retain the remaining input
// span rather than treating the original, now non-contiguous range as valid.
return {
start:
inputStart === promptWithoutDeliveryHintStart &&
decoratedPrompt.slice(requestHeaderStart, decoratedPromptSuffixStart) === requestHeader
? requestHeaderStart
: decoratedPromptSuffixStart + inputStart - promptWithoutDeliveryHintStart,
end: decoratedPromptSuffixStart + inputEnd - promptWithoutDeliveryHintStart,
};
}
function splitLeadingCodexDeliveryHint(prompt: string): {
deliveryHint?: string;
prompt: string;

View File

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

View File

@@ -1,11 +1,6 @@
/**
* 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,
@@ -216,13 +211,6 @@ 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,
@@ -277,74 +265,9 @@ 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 {
if (!isJsonObject(value)) {
return false;
}
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
return isJsonObject(value) && value.willRetry === true;
}
/** Returns true for terminal app-server thread status strings. */
@@ -419,10 +342,6 @@ 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,13 +9,16 @@ 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 {
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import {
leaseSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
@@ -85,12 +88,10 @@ 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();
@@ -101,8 +102,7 @@ function startThreadWithHarness(
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
bindingStore: testCodexAppServerBindingStore,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -125,10 +125,11 @@ function startThreadWithHarness(
sandboxExecServerEnabled: false,
sandbox: null,
contextEngineProjection: undefined,
startupTokenGuard: {},
startupTimeoutMs,
signal,
onStartupTimeout: vi.fn(),
spawnedBy: undefined,
onThreadReserved: overrides?.onThreadReserved,
});
return { harness, run };
@@ -170,12 +171,13 @@ describe("startCodexAttemptThread", () => {
vi.useRealTimers();
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
clearSharedCodexAppServerClient();
resetCodexTestBindingStore();
resetSharedCodexAppServerClientForTests();
});
afterEach(async () => {
vi.useRealTimers();
clearSharedCodexAppServerClient();
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const root of tempRoots) {
@@ -184,7 +186,7 @@ describe("startCodexAttemptThread", () => {
tempRoots.clear();
});
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
@@ -194,25 +196,57 @@ 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 a failed startup client after another active lease releases", async () => {
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 () => {
const retained = createClientHarness();
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLease = getLeasedSharedCodexAppServerClient({
const retainedLeasePromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
@@ -228,17 +262,16 @@ describe("startCodexAttemptThread", () => {
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
retainedLease.release();
const nextLeasePromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
const nextLease = await nextLeasePromise;
expect(nextLease.client).toBe(retained.client);
expect(startSpy).toHaveBeenCalledTimes(1);
nextLease.release();
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
@@ -260,18 +293,20 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
it("retires abandoned thread startup even when another lease shares the client", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLease = getLeasedSharedCodexAppServerClient({
const retainedLeasePromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
await expect(retainedLease).resolves.toBe(retained.client);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
@@ -282,11 +317,9 @@ describe("startCodexAttemptThread", () => {
const threadStart = await waitForThreadStart(retained);
await rejected;
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));
expect(threadStart.id).toBeDefined();
expect(retained.process.stdin.destroyed).toBe(true);
retainedLease.release();
});
it("closes the shared app-server when startup times out during initialize", async () => {
@@ -311,45 +344,37 @@ describe("startCodexAttemptThread", () => {
).toBe(false);
});
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;
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: {},
});
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 { run } = startThreadWithHarness(100, new AbortController().signal, {
harness,
paths,
skipStartSpy: true,
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
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);
});
expect(startSpy).toHaveBeenCalledTimes(1);
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
await peer.abandon();
expect(harness.stdinDestroyed).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {

View File

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

View File

@@ -159,6 +159,39 @@ describe("Codex app-server attempt timeouts", () => {
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("keeps the timeout result when startup resolves during timeout cleanup", async () => {
vi.useFakeTimers();
const events: string[] = [];
let resolveOperation!: (value: string) => void;
let finishCleanup!: () => void;
const run = withCodexStartupTimeout({
timeoutMs: 10,
signal: new AbortController().signal,
onTimeout: async () => {
events.push("cleanup-start");
await new Promise<void>((resolve) => {
finishCleanup = resolve;
});
events.push("cleanup-done");
},
operation: () =>
new Promise<string>((resolve) => {
resolveOperation = resolve;
}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.advanceTimersByTimeAsync(10);
expect(events).toEqual(["cleanup-start"]);
resolveOperation("late-ready");
await Promise.resolve();
expect(events).toEqual(["cleanup-start"]);
finishCleanup();
await rejected;
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("rejects startup timeout when aborted before completion", async () => {
vi.useFakeTimers();
const controller = new AbortController();

View File

@@ -52,13 +52,13 @@ export async function withCodexStartupTimeout<T>(params: {
};
timeout = setTimeout(() => {
timeoutError = new Error("codex app-server startup timed out");
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
);
void timeoutCleanup.finally(() => {
rejectOnce(timeoutError!);
});
rejectOnce(timeoutError);
timeoutCleanup = Promise.resolve()
.then(() => params.onTimeout?.())
.then(
() => undefined,
() => undefined,
);
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });

View File

@@ -29,7 +29,7 @@ describe("Codex app-server attempt turn watches", () => {
const progress: string[] = [];
const diagnostics: string[] = [];
const controller = createCodexAttemptTurnWatchController({
threadId: "thread-1",
getThreadId: () => "thread-1",
signal: abortController.signal,
getTurnId: () => "turn-1",
isCompleted: () => completed,

View File

@@ -29,7 +29,7 @@ export type CodexAttemptTurnWatchController = ReturnType<
* notifications and tool handoffs progress.
*/
export function createCodexAttemptTurnWatchController(params: {
threadId: string;
getThreadId: () => string;
signal: AbortSignal;
getTurnId: () => string | undefined;
isCompleted: () => boolean;
@@ -79,6 +79,7 @@ export function createCodexAttemptTurnWatchController(params: {
const turnTerminalIdleTimeoutMs = resolveTimerTimeoutMs(params.turnTerminalIdleTimeoutMs, 1);
const interruptTimeoutMs = resolveTimerTimeoutMs(params.interruptTimeoutMs, 1);
const resolveWatchTimeoutMs = (timeoutMs: number) => resolveTimerTimeoutMs(timeoutMs, 1);
const currentThreadId = () => params.getThreadId();
const clearCompletionIdleTimer = () => {
if (completionIdleTimer) {
@@ -227,7 +228,7 @@ export function createCodexAttemptTurnWatchController(params: {
clearTerminalIdleTimer();
const turnId = params.getTurnId();
params.onRecordEvent("turn.assistant_completion_idle_release", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -236,7 +237,7 @@ export function createCodexAttemptTurnWatchController(params: {
embeddedAgentLog.warn(
"codex app-server turn released after completed assistant item without terminal event",
{
threadId: params.threadId,
threadId: currentThreadId(),
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -245,7 +246,7 @@ export function createCodexAttemptTurnWatchController(params: {
);
if (turnId) {
params.onInterruptTurn({
threadId: params.threadId,
threadId: currentThreadId(),
turnId,
timeoutMs: interruptTimeoutMs,
});
@@ -278,7 +279,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.progress_idle_timeout", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -286,7 +287,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for progress", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -331,7 +332,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.completion_idle_timeout", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -339,7 +340,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for completion", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -374,7 +375,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.terminal_idle_timeout", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -382,7 +383,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
threadId: params.threadId,
threadId: currentThreadId(),
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -457,9 +458,11 @@ export function createCodexAttemptTurnWatchController(params: {
details?: Record<string, unknown>;
attemptProgress?: boolean;
attemptTimeoutMs?: number;
receivedAtMs?: number;
},
) => {
completionLastActivityAt = Date.now();
const now = Date.now();
completionLastActivityAt = Math.min(now, options?.receivedAtMs ?? now);
completionLastActivityReason = `notification:${method}`;
if (options?.details !== undefined) {
completionLastActivityDetails = options.details;

View File

@@ -3,45 +3,61 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
abortAndDrainAgentHarnessRun,
abortAgentHarnessRun,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
type RunCodexAppServerAttemptOptions = NonNullable<
type RunCodexAppServerAttemptImplOptions = NonNullable<
Parameters<typeof runCodexAppServerAttemptImpl>[1]
>;
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
};
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
codexAppServerClientFactoryForTest = factory;
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
}
function resetCodexAppServerClientFactoryForTest(): void {
codexAppServerClientFactoryForTest = undefined;
codexAppServerClientLeaseFactoryForTest = undefined;
}
function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: RunCodexAppServerAttemptOptions = {},
) {
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
return runCodexAppServerAttemptImpl(
params,
clientFactory ? { ...options, clientFactory } : options,
);
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
return runCodexAppServerAttemptImpl(params, {
...options,
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
});
}
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
registerCodexTestSessionIdentity(
sessionFile,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
);
return {
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
@@ -65,9 +81,10 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
const APP_SERVER_START_WAIT = { interval: 1, timeout: 5_000 } as const;
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -147,7 +164,8 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: unknown) => Promise<void> = async () => undefined;
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | void>();
const requestHandlers = new Set<(request: unknown) => unknown>();
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
@@ -163,19 +181,28 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
addNotificationHandler: (handler: (notification: unknown) => Promise<void> | void) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
},
addRequestHandler: () => () => undefined,
addRequestHandler: (handler: (request: unknown) => unknown) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
addCloseHandler: () => () => undefined,
} as never;
});
const notify = async (notification: unknown) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
};
return {
seenAuthProfileIds,
seenAgentDirs,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), {
...APP_SERVER_START_WAIT,
interval: 1,
});
},
async completeTurn() {
@@ -195,19 +222,14 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
let tmpDir: string;
beforeEach(async () => {
resetCodexTestBindingStore();
vi.useRealTimers();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
});
afterEach(async () => {
vi.useRealTimers();
await abortAndDrainAgentHarnessRun({
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
settleMs: 1_000,
forceClear: true,
reason: "test_cleanup",
});
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
resetCodexAppServerClientFactoryForTest();
await fs.rm(tmpDir, { recursive: true, force: true });
});
@@ -225,7 +247,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
APP_SERVER_START_WAIT,
{ interval: 1 },
);
expect(harness.seenAgentDirs).toEqual([tmpDir]);
await harness.waitForMethod("turn/start");
@@ -236,6 +258,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
@@ -243,7 +266,6 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
dynamicToolsFingerprint: "[]",
});
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
const params = createParams(sessionFile, tmpDir);
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -251,7 +273,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
APP_SERVER_START_WAIT,
{ interval: 1 },
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
@@ -261,13 +283,13 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai:stale",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);
@@ -276,7 +298,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
APP_SERVER_START_WAIT,
{ interval: 1 },
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();

View File

@@ -5,7 +5,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { readModelListResult } from "./models.js";
@@ -27,6 +26,10 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import type {
CodexAppServerClientLease,
CodexAppServerClientLeaseFactory,
} from "./shared-client.js";
import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js";
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
@@ -46,7 +49,7 @@ const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
export type CodexBoundedTurnOptions = {
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientFactory;
clientFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexBoundedTurnResult = {
@@ -118,11 +121,17 @@ async function runBoundedCodexAppServerTurnInWorkspace(
const startOptions = workspace.codexHome
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
: appServer.start;
const ownsClient = !params.options.clientFactory;
let lease: CodexAppServerClientLease | undefined;
const client = params.options.clientFactory
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
? ((lease = await params.options.clientFactory({
startOptions,
timeoutMs,
})
authProfileId: params.profile,
agentDir,
authProfileStore: params.authProfileStore,
config: params.config,
})),
lease.client)
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions,
@@ -208,7 +217,9 @@ async function runBoundedCodexAppServerTurnInWorkspace(
} finally {
clearTimeout(timeout);
params.signal?.removeEventListener("abort", abortFromCaller);
if (ownsClient) {
if (lease) {
lease.release();
} else {
client.close();
}
}

View File

@@ -1,50 +0,0 @@
/**
* Lazy factories for shared and leased Codex app-server clients.
*/
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
type AuthProfileOrderConfig = Parameters<
typeof resolveCodexAppServerAuthProfileIdForAgent
>[0]["config"];
/** Factory signature used by Codex attempt startup to acquire a client. */
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
timeoutMs?: number;
},
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
const loadSharedClientModule = async () => {
sharedClientModulePromise ??= import("./shared-client.js");
return await sharedClientModulePromise;
};
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
refreshAuth: vi.fn(async () => ({ accessToken: "refreshed", chatgptAccountId: "account" })),
mergeRateLimitUpdate: vi.fn(),
}));
vi.mock("./auth-bridge.js", () => ({
refreshCodexAppServerAuthTokens: mocks.refreshAuth,
}));
vi.mock("./rate-limit-cache.js", () => ({
mergeCodexRateLimitsUpdate: mocks.mergeRateLimitUpdate,
}));
const { ensureCodexAppServerClientRuntime } = await import("./client-runtime.js");
describe("Codex app-server client runtime", () => {
const clients: CodexAppServerClient[] = [];
afterEach(() => {
for (const client of clients) {
client.close();
}
clients.length = 0;
mocks.refreshAuth.mockClear();
mocks.mergeRateLimitUpdate.mockClear();
});
it("installs shared handlers once per physical client", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const context = {
agentDir: "/tmp/agent",
authProfileId: "openai:default",
config: {},
};
const updatedContext = {
...context,
authProfileStore: { version: 1 as const, profiles: {} },
config: { models: { mode: "merge" as const } },
};
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
ensureCodexAppServerClientRuntime(harness.client, context);
ensureCodexAppServerClientRuntime(harness.client, updatedContext);
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
expect(addRequestHandler).toHaveBeenCalledTimes(1);
expect(addCloseHandler).not.toHaveBeenCalled();
harness.send({
method: "account/rateLimits/updated",
params: { rateLimits: { primary: { usedPercent: 12 } } },
});
harness.send({
id: "refresh-1",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "expired" },
});
await vi.waitFor(() => expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledTimes(1));
expect(mocks.refreshAuth).toHaveBeenCalledWith(updatedContext);
expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledWith(harness.client, {
rateLimits: { primary: { usedPercent: 12 } },
});
await vi.waitFor(() =>
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
id: "refresh-1",
result: { accessToken: "refreshed", chatgptAccountId: "account" },
}),
);
});
});

View File

@@ -0,0 +1,50 @@
/** Client-scoped Codex auth and account observers. */
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { JsonValue } from "./protocol.js";
import { mergeCodexRateLimitsUpdate } from "./rate-limit-cache.js";
import type { CodexAppServerAuthProfileLookup } from "./session-binding.js";
type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> & {
agentDir: string;
};
type ClientRuntime = {
context: ClientRuntimeContext;
};
const configuredClients = new WeakMap<CodexAppServerClient, ClientRuntime>();
/** Installs one auth-refresh handler and one rate-limit observer per physical client. */
export function ensureCodexAppServerClientRuntime(
client: CodexAppServerClient,
context: ClientRuntimeContext,
): void {
const existing = configuredClients.get(client);
if (existing) {
// Shared-client keys already isolate agent/auth identity. Keep config fresh
// without installing another physical-client handler set.
existing.context = context;
return;
}
const runtime: ClientRuntime = { context };
configuredClients.set(client, runtime);
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return (await refreshCodexAppServerAuthTokens({
agentDir: runtime.context.agentDir,
authProfileId: runtime.context.authProfileId,
...(runtime.context.authProfileStore
? { authProfileStore: runtime.context.authProfileStore }
: {}),
config: runtime.context.config,
})) as unknown as JsonValue;
});
client.addNotificationHandler((notification) => {
if (notification.method === "account/rateLimits/updated") {
mergeCodexRateLimitsUpdate(client, notification.params);
}
});
}

View File

@@ -50,6 +50,78 @@ describe("CodexAppServerClient", () => {
expect(outbound.method).toBe("model/list");
});
it("keeps a shared thread subscribed until every local owner releases it", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const secondResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const [firstRequest, secondRequest] = harness.writes.map((line) => JSON.parse(line)) as Array<{
id: number;
}>;
const resumeResult = {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
};
harness.send({ id: firstRequest?.id, result: resumeResult });
harness.send({ id: secondRequest?.id, result: resumeResult });
await Promise.all([firstResume, secondResume]);
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const finalRelease = harness.client.request("thread/unsubscribe", {
threadId: "thread-1",
});
const releaseRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: releaseRequest.id, result: { status: "unsubscribed" } });
await expect(finalRelease).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(3);
});
it("pairs written resume failures without retaining pre-aborted requests", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const firstRequest = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: firstRequest.id,
result: {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
},
});
await firstResume;
const failedResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const failedRequest = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({ id: failedRequest.id, error: { code: -32000, message: "resume failed" } });
await expect(failedResume).rejects.toThrow("resume failed");
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const controller = new AbortController();
controller.abort();
await expect(
harness.client.request(
"thread/resume",
{ threadId: "thread-1" },
{ signal: controller.signal },
),
).rejects.toThrow("thread/resume aborted");
const unsubscribe = harness.client.request("thread/unsubscribe", { threadId: "thread-1" });
expect(harness.writes).toHaveLength(3);
const unsubscribeRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: unsubscribeRequest.id, result: { status: "unsubscribed" } });
await expect(unsubscribe).resolves.toEqual({ status: "unsubscribed" });
});
it("removes unpaired surrogate code units from outbound JSON-RPC strings", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -70,9 +142,9 @@ describe("CodexAppServerClient", () => {
expect(outbound.params?.nested).toEqual(["lowend", "emoji 🙈 ok"]);
harness.send({
id: JSON.parse(harness.writes[0] ?? "{}").id,
result: { threadId: "thread-1" },
result: { thread: { id: "thread-1" } },
});
await expect(request).resolves.toEqual({ threadId: "thread-1" });
await expect(request).resolves.toEqual({ thread: { id: "thread-1" } });
});
it("logs a redacted preview for malformed app-server messages", async () => {
@@ -140,6 +212,30 @@ describe("CodexAppServerClient", () => {
expect(warn).not.toHaveBeenCalled();
});
it("contains synchronous notification handler failures and continues fanout", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
clients.push(harness.client);
const laterHandler = vi.fn();
harness.client.addNotificationHandler(() => {
throw new Error("handler exploded");
});
harness.client.addNotificationHandler(laterHandler);
expect(() =>
harness.send({
method: "item/commandExecution/outputDelta",
params: { delta: "still routed" },
}),
).not.toThrow();
await vi.waitFor(() => expect(laterHandler).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"codex app-server notification handler failed",
expect.objectContaining({ error: expect.any(Error) }),
);
});
it("preserves JSON-RPC error codes", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -220,6 +316,95 @@ describe("CodexAppServerClient", () => {
expect(harness.writes).toHaveLength(1);
});
it.each([
{
method: "thread/start" as const,
params: {},
abandonment: "timeout" as const,
expectedError: "thread/start timed out",
},
{
method: "thread/fork" as const,
params: { threadId: "parent-thread" },
abandonment: "abort" as const,
expectedError: "thread/fork aborted",
},
])("unsubscribes a late successful $method after local $abandonment", async (testCase) => {
vi.useFakeTimers();
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const options =
testCase.abandonment === "timeout" ? { timeoutMs: 1 } : { signal: controller.signal };
const request = harness.client.request(testCase.method, testCase.params, options);
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow(testCase.expectedError);
if (testCase.abandonment === "timeout") {
await vi.advanceTimersByTimeAsync(100);
} else {
controller.abort();
}
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({
id: expect.any(Number),
method: "thread/unsubscribe",
params: { threadId: "late-thread" },
});
});
it("closes when a late thread creation subscription cannot be released", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
const unsubscribe = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({
id: unsubscribe.id,
error: { code: -32_000, message: "unsubscribe failed" },
});
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true));
});
it("does not unsubscribe a late rejected thread creation", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, error: { code: -32000, message: "start failed" } });
expect(harness.writes).toHaveLength(1);
});
it("closes after the bounded late-creation cleanup ledger fills", async () => {
const harness = createClientHarness();
clients.push(harness.client);
for (let index = 0; index < 129; index += 1) {
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
}
expect(harness.stdinDestroyed).toBe(true);
});
it("initializes with the required client version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
@@ -516,6 +701,26 @@ describe("CodexAppServerClient", () => {
});
});
it.each(["execCommandApproval", "applyPatchApproval"])(
"fails closed for unhandled legacy %s requests",
async (method) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "legacy-approval-1",
method,
params: { conversationId: "thread-1" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "legacy-approval-1",
result: { decision: "denied" },
});
},
);
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -533,6 +738,41 @@ describe("CodexAppServerClient", () => {
});
});
it.each([
[
"item/tool/call",
{
contentItems: [
{
type: "inputText",
text: "OpenClaw did not register a handler for this app-server tool call.",
},
],
success: false,
},
],
["item/permissions/requestApproval", { permissions: {}, scope: "turn" }],
["mcpServer/elicitation/request", { action: "decline" }],
[
"item/future/requestApproval",
{
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
},
],
])("fails closed for an unhandled %s request", async (method, expected) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({ id: "unhandled-1", method, params: { threadId: "thread-1" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "unhandled-1",
result: expected,
});
});
it("only treats known Codex app-server approval methods as approvals", () => {
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);

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